pwn 0.5.784 → 0.5.787

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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  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/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -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)
@@ -464,6 +461,40 @@ module PWN
464
461
  end
465
462
  end
466
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
+
467
498
  # Own the terminal, event pump and the lifetime of exactly one request.
468
499
  class Console # rubocop:disable Metrics/ClassLength -- cohesive single-owner terminal state machine
469
500
  COLORS = { operator: :operator, task: :task, thinking: :notice, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
@@ -616,8 +647,27 @@ module PWN
616
647
 
617
648
  def drain
618
649
  200.times do
619
- type, text = @events.pop(true)
650
+ type, text, generation = @events.pop(true)
651
+ next if generation && generation != @mission_generation && generation != @swarm_generation
652
+
620
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?
621
671
  when :operator_loaded
622
672
  generation, data = text
623
673
  if @operator_view && @operator_view[:generation] == generation
@@ -695,97 +745,230 @@ module PWN
695
745
  PWN::Plugins::Log.mirror_tui!(msg: "#{type.to_s.upcase}\n#{clean}") if @pry.config.pwn_ai_debug && type != :notice
696
746
  end
697
747
 
698
- 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
699
848
  session_id = @pry.config.pwn_ai_session_id
700
849
  title_config = Marshal.load(Marshal.dump(PWN::Env[:ai]))
701
- @locals = Thread.current.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current[key]] }
702
- @thread_variables = Thread.current.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current.thread_variable_get(key)] }
703
- # A fresh forwarding pipe prevents unread input crossing requests.
704
- @tool_input.close unless @tool_input.closed?
850
+ @tool_input&.close unless @tool_input&.closed?
705
851
  forwarded, @tool_input = IO.pipe
706
852
  @input.reopen(forwarded)
707
853
  forwarded.close
708
- @cancelling = false
709
- @command = command
854
+ @cancelling = @command = false
710
855
  begin_request_metrics
711
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)
712
866
  @running = true
713
- ready = Queue.new
714
- gate = Queue.new
867
+ mission = @mission
715
868
  @worker = Thread.new do
716
- @locals.each { |key, value| Thread.current[key] = value }
717
- @thread_variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
718
- control = Control.new(input: @input, output: @output)
719
- Thread.current[:pwn_steering_input] = control
720
- Thread.current[:pwn_usage_observer] = @usage.method(:record)
721
- ready << control
722
- gate.pop
723
- on_tool = lambda do |name, args, result|
724
- if name.to_s == 'task'
725
- @events << [:task, args.to_s]
726
- elsif name.to_s == 'thinking'
727
- @events << [:thinking, args.to_s]
728
- else
729
- @events << [:tool, "#{name}\n#{args.is_a?(String) ? args : args.inspect}"]
730
- @events << [:result, result.to_s]
731
- 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]
732
874
  end
733
- if command
734
- control.checkpoint(messages: [], phase: :tool)
735
- if operation
736
- operation.call
737
- elsif request.match?(%r{\A/model(?:\s|$)})
738
- REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
739
- control.checkpoint(messages: [])
740
- @events << [:model_prompt, selection]
741
- :deferred
742
- })
743
- else
744
- REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry, raise_errors: true)
745
- end
746
- # Maintenance is one safe transaction: never label its committed
747
- # deletions cancelled merely because stop was requested mid-write.
748
- if request.match?(%r{\A/(?:sessions\s+cleanup|maintenance)(?:\s|$)})
749
- @events << [:notice, 'Session cleanup finished; completed work is not undone.']
750
- else
751
- control.checkpoint(messages: [])
752
- end
753
- else
754
- workspace = File.realpath(Dir.pwd)
755
- 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
756
- 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)
757
-
758
- @events << [:session_title, PWN::Sessions.provisional_title(session_id: session_id, request: request)]
759
- final = PWN::AI::Agent::Loop.run(
760
- request: request, session_id: session_id,
761
- enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
762
- on_tool: on_tool, steering: control, debug: @pry.config.pwn_ai_debug, debug_tee: @output
763
- )
764
- promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
765
- @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]
766
935
  end
767
- rescue Control::Stopped => e
768
- @events << [:warning, e.message]
769
- rescue StandardError, Interrupt => e
770
- Thread.current[:pwn_log_progress] = false
771
- PWN::Plugins::Log.note_exception!(error: e, where: 'curses agent', which_self: AIConsole)
772
- @events << [:warning, "#{e.class}: #{e.message}"]
936
+ rescue StandardError => e
937
+ @events << [:warning, "Command IPC: #{e.message}", generation]
773
938
  ensure
774
- workspace_lock&.close
775
- @events << [:done, command ? nil : [session_id, title_config]]
939
+ mission.close
940
+ reader.close
941
+ @events << [:mission_done, nil, generation]
776
942
  end
777
943
  @worker.report_on_exception = false
778
- @control = ready.pop
779
- 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] }
780
956
  end
781
957
 
782
958
  def cancel
959
+ @swarm_generation = SecureRandom.uuid
960
+ if @mission
961
+ cancel_mission
962
+ @swarm&.cancel_all
963
+ return
964
+ end
965
+
783
966
  if busy?
784
967
  first = !@cancelling
785
968
  @cancelling = true
786
969
  @control&.stop
787
970
  @swarm&.cancel_all
788
- @tool_input.close unless @tool_input.closed?
971
+ @tool_input&.close unless @tool_input&.closed?
789
972
  add(:warning, 'Cancellation requested. Waiting for a safe boundary; work already started is not undone.') if first
790
973
  else
791
974
  @editor.replace('')
@@ -793,11 +976,12 @@ module PWN
793
976
  end
794
977
 
795
978
  def stop_worker
979
+ cancel_mission if @mission
796
980
  return unless @worker&.alive? || @swarm&.busy?
797
981
 
798
982
  @control&.stop
799
983
  @swarm&.cancel_all
800
- @tool_input.close unless @tool_input.closed?
984
+ @tool_input&.close unless @tool_input&.closed?
801
985
  @worker&.join
802
986
  @swarm&.close
803
987
  ensure
@@ -914,7 +1098,7 @@ module PWN
914
1098
 
915
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})
916
1100
 
917
- 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)))
918
1102
  text = result[:job_id] ? "Swarm job #{result[:job_id]} queued (#{result[:state]})." : (result[:usage] || REPL.pwn_ai_result_text(value: result))
919
1103
  add(:command, text)
920
1104
  end
@@ -2410,13 +2594,9 @@ module PWN
2410
2594
 
2411
2595
  view = @operator_view
2412
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] }] }
2413
- session_id = @pry.config.pwn_ai_session_id
2414
2597
  view.delete(:confirm)
2415
2598
  @operator_view = nil
2416
- start_request('/sessions cleanup (approved preview)', command: true) do
2417
- result = PWN::SessionCleanup.run(dry_run: false, current_session_id: session_id, approved_candidates: approved)
2418
- @events << [:command, REPL.pwn_ai_result_text(value: result)]
2419
- end
2599
+ start_request('/sessions cleanup (approved preview)', command: true, cleanup: approved)
2420
2600
  end
2421
2601
 
2422
2602
  def operator_viewport(page)
@@ -2581,7 +2761,7 @@ module PWN
2581
2761
  @workspace[:notice] = 'Workspace busy: wait or cancel the ordinary request first.'
2582
2762
  return
2583
2763
  end
2584
- 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))
2585
2765
  options[:solve_request] = args.last if args.first == 'mission'
2586
2766
  result = @swarm.execute(options)
2587
2767
  @workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"