pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -28,8 +28,8 @@ module PWN
28
28
  end
29
29
 
30
30
  # Private, in-memory configuration transaction. No plaintext filesystem stage.
31
- # Compatible with pwn-vault AES-256-CBC and its existing decryptor sidecar.
32
- class VaultEdit # rubocop:disable Metrics/ClassLength -- One private encrypted transaction owns validation and atomic persistence.
31
+ # Uses the shared vault codec for legacy sidecars and YubiKey envelopes.
32
+ class VaultEdit
33
33
  class Error < StandardError; end
34
34
 
35
35
  attr_reader :text
@@ -82,15 +82,17 @@ module PWN
82
82
  end
83
83
 
84
84
  def serialize(value)
85
- YAML.dump(value).gsub(/^(\s*):/, '\1')
85
+ YAML.dump(value)
86
86
  end
87
87
 
88
88
  def credentials
89
+ return PWN::Plugins::Vault.access_options(file: @path) if PWN::Plugins::Vault.hardware_encrypted?(file: @path)
90
+
89
91
  safe_file(@decryptor_path)
90
92
  result = parse(File.read(@decryptor_path))
91
93
  raise Error, 'Decryptor requires the existing key and IV; restore the original sidecar.' unless result.is_a?(Hash) && result[:key].is_a?(String) && result[:iv].is_a?(String)
92
94
 
93
- result.slice(:key, :iv)
95
+ PWN::Plugins::Vault.access_options(result.slice(:key, :iv).merge(file: @path, decryptor_file: @decryptor_path))
94
96
  end
95
97
 
96
98
  def safe_file(path)
@@ -154,12 +156,7 @@ module PWN
154
156
  end
155
157
 
156
158
  def encrypt(text)
157
- secrets = credentials
158
- cipher = OpenSSL::Cipher.new('aes-256-cbc')
159
- cipher.encrypt
160
- cipher.key = Base64.strict_decode64(secrets.fetch(:key))
161
- cipher.iv = Base64.strict_decode64(secrets.fetch(:iv))
162
- "#{Base64.strict_encode64(cipher.update(text) + cipher.final)}\n"
159
+ PWN::Plugins::Vault.encrypt_data(credentials.merge(data: text))
163
160
  end
164
161
 
165
162
  def atomic_write(encrypted, previous)
@@ -277,6 +274,10 @@ module PWN
277
274
  class Control < PWN::AI::Agent::Loop::Steering
278
275
  class Stopped < Exception; end # rubocop:disable Lint/InheritException
279
276
 
277
+ def stopping?
278
+ @stopping == true
279
+ end
280
+
280
281
  def stop
281
282
  @stopping = true
282
283
  request_model_cancel
@@ -460,6 +461,40 @@ module PWN
460
461
  end
461
462
  end
462
463
 
464
+ # JSON-only, generation-tagged child events; never deserialize Ruby objects.
465
+ class MissionEvents
466
+ def initialize(io, generation)
467
+ @io = io
468
+ @generation = generation
469
+ @mutex = Mutex.new
470
+ end
471
+
472
+ def <<(event)
473
+ @mutex.synchronize { @io.write("#{JSON.generate([@generation, *event])}\n") }
474
+ self
475
+ end
476
+ end
477
+
478
+ # Parent-side steering endpoint; cancellation belongs to the process owner.
479
+ class MissionControl
480
+ def initialize(io)
481
+ @io = io
482
+ end
483
+
484
+ def submit(text)
485
+ @io.puts(JSON.generate(text.to_s))
486
+ @io.flush
487
+ end
488
+
489
+ def phase
490
+ 'mission process'
491
+ end
492
+
493
+ def stop
494
+ :stopping
495
+ end
496
+ end
497
+
463
498
  # Own the terminal, event pump and the lifetime of exactly one request.
464
499
  class Console # rubocop:disable Metrics/ClassLength -- cohesive single-owner terminal state machine
465
500
  COLORS = { operator: :operator, task: :task, thinking: :notice, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
@@ -612,8 +647,27 @@ module PWN
612
647
 
613
648
  def drain
614
649
  200.times do
615
- type, text = @events.pop(true)
650
+ type, text, generation = @events.pop(true)
651
+ next if generation && generation != @mission_generation && generation != @swarm_generation
652
+
616
653
  case type
654
+ when :command_state
655
+ if text[:ai].is_a?(Hash)
656
+ text[:ai][:active] = text[:ai][:active].to_sym if PWN::Env.dig(:ai, :active).is_a?(Symbol) && text[:ai][:active].is_a?(String)
657
+ PWN::Env[:ai] = text[:ai]
658
+ end
659
+ text.fetch(:config, {}).each do |key, value|
660
+ @pry.config.public_send("#{key}=", value) if %i[pwn_ai_session_id pwn_ai_debug pwn_ai_trace].include?(key)
661
+ end
662
+ when :usage
663
+ @usage.record(text)
664
+ when :mission_done
665
+ @worker.join
666
+ finish_request_metrics
667
+ @running = false
668
+ @control = nil
669
+ @mission = nil
670
+ @mission_commands&.close unless @mission_commands&.closed?
617
671
  when :operator_loaded
618
672
  generation, data = text
619
673
  if @operator_view && @operator_view[:generation] == generation
@@ -691,97 +745,230 @@ module PWN
691
745
  PWN::Plugins::Log.mirror_tui!(msg: "#{type.to_s.upcase}\n#{clean}") if @pry.config.pwn_ai_debug && type != :notice
692
746
  end
693
747
 
694
- def start_request(request, command: false, &operation)
748
+ private_class_method def self.mission_entry(opts = {})
749
+ data = opts[:descriptor]
750
+ console = allocate
751
+ pry = Pry.new
752
+ data.fetch(:config).each { |key, value| pry.config.public_send("#{key}=", value) }
753
+ console.instance_variable_set(:@pry, pry)
754
+ console.instance_variable_set(:@input, PWN::AI::Agent::MissionNamespace.io(5))
755
+ writer = PWN::AI::Agent::MissionNamespace.io(4)
756
+ if data[:operation] == :console
757
+ console.send(:mission_request_child, data, writer, PWN::AI::Agent::MissionNamespace.io(6))
758
+ else
759
+ console.send(:mission_command_child, data[:request], data[:generation], data[:baseline_ai], writer, data[:cleanup])
760
+ end
761
+ end
762
+
763
+ def mission_config
764
+ %i[pwn_ai_session_id pwn_ai_debug pwn_ai_trace pwn_ai_speak pwn_ai_agent].to_h { |key| [key, @pry.config.public_send(key)] }
765
+ end
766
+
767
+ def mission_request_child(data, writer, commands)
768
+ request, generation, session_id, title_config = data.values_at(:request, :generation, :session_id, :title_config)
769
+ events = MissionEvents.new(writer, generation)
770
+ output = EventIO.new(events)
771
+ $stdout = $stderr = output
772
+ $stdin = @input
773
+ @pry.config.output = output
774
+ control = Control.new(input: @input, output: output)
775
+ Thread.current[:pwn_steering_input] = control
776
+ Thread.current[:pwn_usage_observer] = ->(usage) { events << [:usage, usage] }
777
+ Thread.new do
778
+ commands.each_line { |line| control.submit(JSON.parse(line)) }
779
+ end
780
+ begin
781
+ workspace = File.realpath(Dir.pwd)
782
+ lock_path = File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock")
783
+ File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lease|
784
+ raise IOError, 'Workspace busy: another mission owns this directory.' unless lease.flock(File::LOCK_EX | File::LOCK_NB)
785
+
786
+ events << [:session_title, PWN::Sessions.provisional_title(session_id: session_id, request: request)]
787
+ on_tool = lambda do |name, args, result|
788
+ if %w[task thinking].include?(name.to_s)
789
+ events << [name.to_sym, args.to_s]
790
+ else
791
+ events << [:tool, "#{name}\n#{args.is_a?(String) ? args : args.inspect}"]
792
+ events << [:result, result.to_s]
793
+ end
794
+ end
795
+ final = PWN::AI::Agent::Loop.run(request: request, session_id: session_id,
796
+ enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
797
+ on_tool: on_tool, steering: control, debug: @pry.config.pwn_ai_debug,
798
+ trace: @pry.config.pwn_ai_trace, debug_tee: output)
799
+ promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
800
+ events << [:assistant, final.to_s] unless promoted
801
+ events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id, ai_config: title_config, on_error: ->(kind) { events << [:warning, "Session title refresh unavailable (#{kind}); previous title retained."] })]
802
+ end
803
+ rescue StandardError, Interrupt, Control::Stopped => e
804
+ events << [:warning, "#{e.class}: #{e.message}"]
805
+ ensure
806
+ output.flush
807
+ writer.close
808
+ end
809
+ end
810
+
811
+ def mission_command_child(request, generation, baseline_ai, writer, cleanup)
812
+ events = MissionEvents.new(writer, generation)
813
+ output = EventIO.new(events)
814
+ $stdout = $stderr = output
815
+ $stdin = @input
816
+ @pry.config.output = output
817
+ Thread.current[:pwn_steering_input] = Control.new(input: @input, output: output)
818
+ @events = events
819
+ @output = output
820
+ begin
821
+ if cleanup
822
+ result = PWN::SessionCleanup.run(dry_run: false, current_session_id: @pry.config.pwn_ai_session_id, approved_candidates: cleanup)
823
+ events << [:command, REPL.pwn_ai_result_text(value: result)]
824
+ elsif request.match?(%r{\A/model(?:\s|$)})
825
+ REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
826
+ events << [:model_prompt, selection]
827
+ :deferred
828
+ })
829
+ else
830
+ REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry, raise_errors: true)
831
+ end
832
+ config = %i[pwn_ai_session_id pwn_ai_debug pwn_ai_trace].to_h { |key| [key, @pry.config.public_send(key)] }
833
+ ai = PWN::Env[:ai] unless JSON.generate(PWN::Env[:ai]) == baseline_ai
834
+ events << [:command_state, { ai: ai, config: config }]
835
+ rescue StandardError, Interrupt, Control::Stopped => e
836
+ events << [:warning, "#{e.class}: #{e.message}"]
837
+ ensure
838
+ output.flush
839
+ writer.close
840
+ end
841
+ end
842
+
843
+ def start_mission_request(request)
844
+ return add(:warning, 'A mission is still running; cancel and reap it first.') if busy?
845
+
846
+ @mission_generation = SecureRandom.uuid
847
+ generation = @mission_generation
695
848
  session_id = @pry.config.pwn_ai_session_id
696
849
  title_config = Marshal.load(Marshal.dump(PWN::Env[:ai]))
697
- @locals = Thread.current.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current[key]] }
698
- @thread_variables = Thread.current.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current.thread_variable_get(key)] }
699
- # A fresh forwarding pipe prevents unread input crossing requests.
700
- @tool_input.close unless @tool_input.closed?
850
+ @tool_input&.close unless @tool_input&.closed?
701
851
  forwarded, @tool_input = IO.pipe
702
852
  @input.reopen(forwarded)
703
853
  forwarded.close
704
- @cancelling = false
705
- @command = command
854
+ @cancelling = @command = false
706
855
  begin_request_metrics
707
856
  add(:operator, request)
857
+ reader, writer = IO.pipe
858
+ commands, @mission_commands = IO.pipe
859
+ @mission = PWN::Plugins::AISandbox::MissionProcess.new(
860
+ descriptor: { operation: :console, request: request, generation: generation, session_id: session_id, title_config: title_config, config: mission_config },
861
+ fds: { 4 => writer, 5 => @input, 6 => commands }
862
+ )
863
+ writer.close
864
+ commands.close
865
+ @control = MissionControl.new(@mission_commands)
708
866
  @running = true
709
- ready = Queue.new
710
- gate = Queue.new
867
+ mission = @mission
711
868
  @worker = Thread.new do
712
- @locals.each { |key, value| Thread.current[key] = value }
713
- @thread_variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
714
- control = Control.new(input: @input, output: @output)
715
- Thread.current[:pwn_steering_input] = control
716
- Thread.current[:pwn_usage_observer] = @usage.method(:record)
717
- ready << control
718
- gate.pop
719
- on_tool = lambda do |name, args, result|
720
- if name.to_s == 'task'
721
- @events << [:task, args.to_s]
722
- elsif name.to_s == 'thinking'
723
- @events << [:thinking, args.to_s]
724
- else
725
- @events << [:tool, "#{name}\n#{args.is_a?(String) ? args : args.inspect}"]
726
- @events << [:result, result.to_s]
727
- end
869
+ reader.each_line do |line|
870
+ tag, type, text = JSON.parse(line, symbolize_names: true)
871
+ next unless tag == generation && %w[task thinking tool result assistant notice warning session_title usage].include?(type)
872
+
873
+ @events << [type.to_sym, text, tag]
728
874
  end
729
- if command
730
- control.checkpoint(messages: [], phase: :tool)
731
- if operation
732
- operation.call
733
- elsif request.match?(%r{\A/model(?:\s|$)})
734
- REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
735
- control.checkpoint(messages: [])
736
- @events << [:model_prompt, selection]
737
- :deferred
738
- })
739
- else
740
- REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry, raise_errors: true)
741
- end
742
- # Maintenance is one safe transaction: never label its committed
743
- # deletions cancelled merely because stop was requested mid-write.
744
- if request.match?(%r{\A/sessions\s+cleanup(?:\s|$)})
745
- @events << [:notice, 'Session cleanup finished; completed work is not undone.']
746
- else
747
- control.checkpoint(messages: [])
748
- end
749
- else
750
- workspace = File.realpath(Dir.pwd)
751
- workspace_lock = File.open(File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- worker ensure owns the lease
752
- raise IOError, 'Workspace busy: another request or mission owns this directory. Wait or cancel it before retrying.' unless workspace_lock.flock(File::LOCK_EX | File::LOCK_NB)
753
-
754
- @events << [:session_title, PWN::Sessions.provisional_title(session_id: session_id, request: request)]
755
- final = PWN::AI::Agent::Loop.run(
756
- request: request, session_id: session_id,
757
- enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
758
- on_tool: on_tool, steering: control, debug: @pry.config.pwn_ai_debug, debug_tee: @output
759
- )
760
- promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
761
- @events << [:assistant, final.to_s] unless promoted
875
+ rescue StandardError => e
876
+ @events << [:warning, "Mission IPC: #{e.message}", generation]
877
+ ensure
878
+ mission.close
879
+ reader.close
880
+ @events << [:mission_done, nil, generation]
881
+ end
882
+ @worker.report_on_exception = false
883
+ rescue StandardError => e
884
+ [reader, writer, commands, @mission_commands].compact.each { |io| io.close unless io.closed? }
885
+ @mission&.close
886
+ @mission = nil
887
+ @running = false
888
+ add(:warning, "Mission not started: #{e.message}")
889
+ end
890
+
891
+ def cancel_mission
892
+ # Invalidate queued events first. Ready is set only after the reader
893
+ # reaps namespace PID 1, including all provider and tool descendants.
894
+ @cancelling = true
895
+ @mission_generation = nil
896
+ @mission.close
897
+ @worker&.join
898
+ @mission_commands&.close unless @mission_commands&.closed?
899
+ @tool_input&.close unless @tool_input&.closed?
900
+ @mission = @control = nil
901
+ @running = false
902
+ finish_request_metrics
903
+ add(:warning, 'Mission cancelled; all owned processes reaped. Completed work is not undone.')
904
+ end
905
+
906
+ def start_request(request, command: false, cleanup: nil)
907
+ return start_mission_request(request) unless command
908
+ return add(:warning, 'A mission is still running; cancel and reap it first.') if busy?
909
+
910
+ @mission_generation = SecureRandom.uuid
911
+ generation = @mission_generation
912
+ @tool_input&.close unless @tool_input&.closed?
913
+ forwarded, @tool_input = IO.pipe
914
+ @input.reopen(forwarded)
915
+ forwarded.close
916
+ @cancelling = false
917
+ @command = true
918
+ baseline_ai = JSON.generate(PWN::Env[:ai])
919
+ begin_request_metrics
920
+ add(:operator, request)
921
+ reader, writer = IO.pipe
922
+ @mission = PWN::Plugins::AISandbox::MissionProcess.new(
923
+ descriptor: { operation: :slash, request: request, generation: generation, baseline_ai: baseline_ai, config: mission_config, cleanup: cleanup },
924
+ fds: { 4 => writer, 5 => @input }
925
+ )
926
+ writer.close
927
+ @running = true
928
+ mission = @mission
929
+ @worker = Thread.new do
930
+ reader.each_line do |line|
931
+ tag, type, text = JSON.parse(line, symbolize_names: true)
932
+ next unless tag == generation && %w[notice warning command command_notice model_prompt command_state].include?(type)
933
+
934
+ @events << [type.to_sym, text, tag]
762
935
  end
763
- rescue Control::Stopped => e
764
- @events << [:warning, e.message]
765
- rescue StandardError, Interrupt => e
766
- Thread.current[:pwn_log_progress] = false
767
- PWN::Plugins::Log.note_exception!(error: e, where: 'curses agent', which_self: AIConsole)
768
- @events << [:warning, "#{e.class}: #{e.message}"]
936
+ rescue StandardError => e
937
+ @events << [:warning, "Command IPC: #{e.message}", generation]
769
938
  ensure
770
- workspace_lock&.close
771
- @events << [:done, command ? nil : [session_id, title_config]]
939
+ mission.close
940
+ reader.close
941
+ @events << [:mission_done, nil, generation]
772
942
  end
773
943
  @worker.report_on_exception = false
774
- @control = ready.pop
775
- gate << true
944
+ rescue StandardError => e
945
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
946
+ @mission&.close
947
+ @mission = nil
948
+ @running = false
949
+ add(:warning, "Command not started: #{e.message}")
950
+ end
951
+
952
+ def swarm_event_callback
953
+ @swarm_generation ||= SecureRandom.uuid
954
+ generation = @swarm_generation
955
+ ->(type, text) { @events << [type, text, generation] }
776
956
  end
777
957
 
778
958
  def cancel
959
+ @swarm_generation = SecureRandom.uuid
960
+ if @mission
961
+ cancel_mission
962
+ @swarm&.cancel_all
963
+ return
964
+ end
965
+
779
966
  if busy?
780
967
  first = !@cancelling
781
968
  @cancelling = true
782
969
  @control&.stop
783
970
  @swarm&.cancel_all
784
- @tool_input.close unless @tool_input.closed?
971
+ @tool_input&.close unless @tool_input&.closed?
785
972
  add(:warning, 'Cancellation requested. Waiting for a safe boundary; work already started is not undone.') if first
786
973
  else
787
974
  @editor.replace('')
@@ -789,11 +976,12 @@ module PWN
789
976
  end
790
977
 
791
978
  def stop_worker
979
+ cancel_mission if @mission
792
980
  return unless @worker&.alive? || @swarm&.busy?
793
981
 
794
982
  @control&.stop
795
983
  @swarm&.cancel_all
796
- @tool_input.close unless @tool_input.closed?
984
+ @tool_input&.close unless @tool_input&.closed?
797
985
  @worker&.join
798
986
  @swarm&.close
799
987
  ensure
@@ -848,7 +1036,7 @@ module PWN
848
1036
  open_vault_edit(line)
849
1037
  elsif line.strip == '/system-role'
850
1038
  open_system_role
851
- elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
1039
+ elsif line.match?(%r{\A/(?:maintenance(?:\s|$)|mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
852
1040
  start_request(line, command: true)
853
1041
  elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
854
1042
  if line.match?(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]|$)})
@@ -910,7 +1098,7 @@ module PWN
910
1098
 
911
1099
  return add(:warning, 'Workspace busy: wait or cancel the ordinary request before launching a swarm writer.') if @running && line.match?(%r{\A/swarm\s+(?:mission|dm|broadcast|debate)\b})
912
1100
 
913
- result = @swarm.execute(mission_options.merge(line: line, on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record)))
1101
+ result = @swarm.execute(mission_options.merge(line: line, on_event: swarm_event_callback, usage_observer: @usage.method(:record)))
914
1102
  text = result[:job_id] ? "Swarm job #{result[:job_id]} queued (#{result[:state]})." : (result[:usage] || REPL.pwn_ai_result_text(value: result))
915
1103
  add(:command, text)
916
1104
  end
@@ -2406,13 +2594,9 @@ module PWN
2406
2594
 
2407
2595
  view = @operator_view
2408
2596
  approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], { files: row[:files], dependency_records: row[:dependency_records] }] }
2409
- session_id = @pry.config.pwn_ai_session_id
2410
2597
  view.delete(:confirm)
2411
2598
  @operator_view = nil
2412
- start_request('/sessions cleanup (approved preview)', command: true) do
2413
- result = PWN::SessionCleanup.run(dry_run: false, current_session_id: session_id, approved_candidates: approved)
2414
- @events << [:command, REPL.pwn_ai_result_text(value: result)]
2415
- end
2599
+ start_request('/sessions cleanup (approved preview)', command: true, cleanup: approved)
2416
2600
  end
2417
2601
 
2418
2602
  def operator_viewport(page)
@@ -2577,7 +2761,7 @@ module PWN
2577
2761
  @workspace[:notice] = 'Workspace busy: wait or cancel the ordinary request first.'
2578
2762
  return
2579
2763
  end
2580
- options = mission_options.merge(line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record))
2764
+ options = mission_options.merge(line: Shellwords.join(['/swarm'] + args), on_event: swarm_event_callback, usage_observer: @usage.method(:record))
2581
2765
  options[:solve_request] = args.last if args.first == 'mission'
2582
2766
  result = @swarm.execute(options)
2583
2767
  @workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"