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
@@ -49,6 +49,29 @@ module PWN
49
49
  end
50
50
  end
51
51
 
52
+ # Parent-side typed control pipe for one namespace-owned job.
53
+ class RemoteControl
54
+ attr_accessor :observed_pause
55
+
56
+ def initialize(io)
57
+ @io = io
58
+ end
59
+
60
+ def submit(text)
61
+ send_control('submit', text)
62
+ end
63
+
64
+ def pause(value)
65
+ send_control('pause', value)
66
+ end
67
+
68
+ private
69
+
70
+ def send_control(action, value)
71
+ @io.write("#{JSON.generate([action, value])}\n")
72
+ end
73
+ end
74
+
52
75
  # Session-scoped swarm jobs. Workers never draw the terminal.
53
76
  class Controller # rubocop:disable Metrics/ClassLength -- one session-scoped swarm command surface
54
77
  MAX_JOBS = 8
@@ -164,7 +187,7 @@ module PWN
164
187
  when 'create' then create(tokens.join(' '))
165
188
  when 'use' then use(tokens.first)
166
189
  when 'spawn' then spawn(tokens)
167
- when 'retire' then retire(tokens.first)
190
+ when 'retire' then retire(tokens.first, continuation: tokens.include?('--continuation'))
168
191
  when 'dm' then ask(tokens, opts)
169
192
  when 'mission' then solve(tokens, opts)
170
193
  when 'debate' then debate(tokens, opts)
@@ -180,7 +203,7 @@ module PWN
180
203
  def help_text
181
204
  {
182
205
  ok: true,
183
- usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
206
+ usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME [--continuation]|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
184
207
  }
185
208
  end
186
209
 
@@ -215,10 +238,10 @@ module PWN
215
238
  { ok: true, persona: result[:name], swarm_id: @swarm_id, scope: :swarm }
216
239
  end
217
240
 
218
- def retire(name)
241
+ def retire(name, continuation: false)
219
242
  return { ok: false, error: 'invalid persona name' } unless identifier?(name)
220
243
 
221
- result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true)
244
+ result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true, continuation: continuation)
222
245
  { ok: result[:removed], scope: result[:scope], name: name }
223
246
  end
224
247
 
@@ -227,23 +250,14 @@ module PWN
227
250
  request = tokens.join(' ')
228
251
  return { ok: false, error: 'usage: dm NAME REQUEST' } if name.to_s.empty? || request.empty?
229
252
 
230
- start_job(agent: name, command: 'dm', opts: opts) do |job|
231
- PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
232
- end
253
+ start_job(agent: name, command: 'dm', opts: opts, task: { name: name, request: request })
233
254
  end
234
255
 
235
256
  def solve(tokens, opts)
236
257
  request = opts.fetch(:solve_request, tokens.join(' '))
237
258
  return { ok: false, error: 'usage: mission REQUEST' } if request.strip.empty?
238
259
 
239
- start_job(agent: 'team', command: 'mission', opts: opts) do |job|
240
- PWN::AI::Agent::Swarm.solve(
241
- request: request, swarm_id: ensure_swarm, steering: job[:control],
242
- worktree: opts.fetch(:worktree, false), rounds: opts.fetch(:rounds, 3), verification_timeout: opts.fetch(:verification_timeout, 60), role_timeout: opts.fetch(:role_timeout, 300),
243
- workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
244
- on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
245
- )
246
- end
260
+ start_job(agent: 'team', command: 'mission', opts: opts, task: { request: request })
247
261
  end
248
262
 
249
263
  def debate(tokens, opts)
@@ -254,18 +268,7 @@ module PWN
254
268
  return { ok: false, error: 'usage: debate NAME,NAME TOPIC [--rounds N]' } if names.length < 2 || topic.empty?
255
269
  return { ok: false, error: 'rounds must be between 1 and 10' } unless rounds.between?(1, 10)
256
270
 
257
- start_job(agent: names.join(','), command: 'debate', opts: opts) do |job|
258
- transcript = []
259
- rounds.times do
260
- names.each do |name|
261
- request = transcript.empty? ? topic : "#{topic}\n\nPrevious speaker: #{transcript.last}\nRespond, critique, or advance the objective."
262
- job[:control].check_stop
263
- result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: @swarm_id, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
264
- transcript << "#{name}: #{result[:reply]}"
265
- end
266
- end
267
- { reply: transcript.join("\n") }
268
- end
271
+ start_job(agent: names.join(','), command: 'debate', opts: opts, task: { names: names, topic: topic, rounds: rounds })
269
272
  end
270
273
 
271
274
  def broadcast(tokens, opts)
@@ -274,14 +277,7 @@ module PWN
274
277
  names = flags['names']&.split(',') || agents
275
278
  return { ok: false, error: 'usage: broadcast REQUEST [--names a,b]' } if request.empty? || names.empty?
276
279
 
277
- start_job(agent: names.join(','), command: 'broadcast', opts: opts) do |job|
278
- replies = names.map do |name|
279
- job[:control].check_stop
280
- result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
281
- "#{name}: #{result[:reply]}"
282
- end
283
- { reply: replies.join("\n") }
284
- end
280
+ start_job(agent: names.join(','), command: 'broadcast', opts: opts, task: { names: names, request: request })
285
281
  end
286
282
 
287
283
  def steer(tokens, opts)
@@ -315,7 +311,9 @@ module PWN
315
311
 
316
312
  return { ok: false, error: 'job is no longer active' } unless update_active(job, 'cancelling')
317
313
 
318
- job[:control].stop
314
+ job[:mission].close
315
+ job[:worker]&.join
316
+ update(job, 'cancelled', error: 'Mission cancelled; all owned processes reaped.')
319
317
  { ok: true, job_id: job[:id] }
320
318
  end
321
319
 
@@ -331,7 +329,92 @@ module PWN
331
329
  { ok: true, messages: PWN::AI::Agent::Swarm.bus_tail(swarm_id: @swarm_id, limit: (limit || 8).to_i) }
332
330
  end
333
331
 
334
- def start_job(agent:, command:, opts:)
332
+ private_class_method def self.mission_entry(opts = {})
333
+ data = opts[:descriptor]
334
+ controller = new(session_id: data[:session_id])
335
+ controller.instance_variable_set(:@swarm_id, data[:swarm_id])
336
+ controller.send(:mission_child, data[:job], data[:task], data[:options], PWN::AI::Agent::MissionNamespace.io(4), PWN::AI::Agent::MissionNamespace.io(5))
337
+ end
338
+
339
+ def mission_child(job, task, opts, writer, commands)
340
+ id = job[:id]
341
+ command = job[:command]
342
+ @child_events = AIConsole::MissionEvents.new(writer, id)
343
+ output = AIConsole::EventIO.new(@child_events)
344
+ $stdout = $stderr = output
345
+ opts[:on_event] = ->(type, text) { @child_events << [:event, [type, text]] }
346
+ opts[:usage_observer] = ->(usage) { @child_events << [:usage, usage] }
347
+ control_class = command == 'mission' ? MissionControl : JobControl
348
+ job[:control] = control_class.new(input: StringIO.new, output: StringIO.new)
349
+ Thread.current[:pwn_steering_input] = job[:control]
350
+ commands_thread = Thread.new do
351
+ commands.each_line do |line|
352
+ action, value = JSON.parse(line)
353
+ job[:control].submit(value) if action == 'submit'
354
+ job[:control].pause(value) if action == 'pause' && command == 'mission'
355
+ end
356
+ end
357
+ snapshot_thread = Thread.new do
358
+ loop do
359
+ @child_events << [:snapshot, { team: job[:team], paused: command == 'mission' && job[:control].observed_pause }]
360
+ sleep 0.05
361
+ end
362
+ end
363
+ begin
364
+ result = mission_operation(command, task, job, opts)
365
+ job[:control].check_stop unless command == 'mission'
366
+ state = command == 'mission' ? result[:status] : 'completed'
367
+ @child_events << [:complete, { state: state, result: summarize(result), team: command == 'mission' ? (job[:team] || {}).merge(result) : nil }]
368
+ rescue JobControl::Stopped, PWN::AI::Agent::Solve::Control::Stopped => e
369
+ @child_events << [:complete, { state: 'cancelled', error: e.message }]
370
+ rescue StandardError => e
371
+ @child_events << [:complete, { state: 'failed', error: "#{e.class}: #{e.message}" }]
372
+ ensure
373
+ commands_thread.kill.join
374
+ snapshot_thread.kill.join
375
+ output.flush
376
+ writer.close
377
+ end
378
+ end
379
+
380
+ def mission_operation(command, task, job, opts)
381
+ name = task[:name]
382
+ request = task[:request]
383
+ names = task[:names]
384
+ topic = task[:topic]
385
+ rounds = task[:rounds]
386
+ case command
387
+ when 'dm'
388
+ PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
389
+ when 'mission'
390
+ PWN::AI::Agent::Swarm.solve(
391
+ request: request, swarm_id: ensure_swarm, steering: job[:control],
392
+ worktree: opts.fetch(:worktree, false), rounds: opts.fetch(:rounds, 3), verification_timeout: opts.fetch(:verification_timeout, 60), role_timeout: opts.fetch(:role_timeout, 300),
393
+ workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
394
+ on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
395
+ )
396
+ when 'debate'
397
+ transcript = []
398
+ rounds.times do
399
+ names.each do |name|
400
+ request = transcript.empty? ? topic : "#{topic}\n\nPrevious speaker: #{transcript.last}\nRespond, critique, or advance the objective."
401
+ job[:control].check_stop
402
+ result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: @swarm_id, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
403
+ transcript << "#{name}: #{result[:reply]}"
404
+ end
405
+ end
406
+ { reply: transcript.join("\n") }
407
+ when 'broadcast'
408
+ replies = names.map do |name|
409
+ job[:control].check_stop
410
+ result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
411
+ "#{name}: #{result[:reply]}"
412
+ end
413
+ { reply: replies.join("\n") }
414
+ end
415
+ end
416
+
417
+ def start_job(agent:, command:, opts:, task:)
335
418
  return { ok: false, error: 'swarm controller is closed' } if @closed
336
419
  return { ok: false, error: 'mission requires exclusive workspace ownership; wait or cancel active jobs' } if (command == 'mission' && busy?) || snapshot[:jobs].any? { |row| row[:command] == 'mission' && ACTIVE.include?(row[:state]) }
337
420
 
@@ -339,36 +422,71 @@ module PWN
339
422
 
340
423
  ensure_swarm
341
424
  id = SecureRandom.hex(3)
342
- locals = Thread.current.keys.grep(/^pwn_/).to_h { |key| [key, Thread.current[key]] }
343
- variables = Thread.current.thread_variables.grep(/^pwn_/).to_h { |key| [key, Thread.current.thread_variable_get(key)] }
344
- ready = Queue.new
345
- job = { id: id, agent: agent, command: command, state: 'queued' }
425
+ reader, writer = IO.pipe
426
+ commands, sender = IO.pipe
427
+ job = { id: id, agent: agent, command: command, state: 'queued', sender: sender }
346
428
  @mutex.synchronize { @jobs[id] = job }
429
+ on_event = opts[:on_event]
430
+ on_usage = opts[:usage_observer]
431
+ job[:mission] = PWN::Plugins::AISandbox::MissionProcess.new(
432
+ descriptor: { operation: :swarm, session_id: @session_id, swarm_id: @swarm_id, job: job.slice(:id, :agent, :command), task: task, options: opts.except(:on_event, :usage_observer) },
433
+ fds: { 4 => writer, 5 => commands }
434
+ )
435
+ writer.close
436
+ commands.close
437
+ job[:control] = RemoteControl.new(sender)
438
+ update(job, 'running')
347
439
  job[:worker] = Thread.new do
348
- locals.each { |key, value| Thread.current[key] = value }
349
- variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
350
- control_class = command == 'mission' ? MissionControl : JobControl
351
- job[:control] = control_class.new(input: StringIO.new, output: StringIO.new)
352
- Thread.current[:pwn_steering_input] = job[:control]
353
- ready << true
354
- update(job, 'running')
355
- result = yield(job)
356
- job[:control].check_stop unless command == 'mission'
357
- state = command == 'mission' ? result[:status] : 'completed'
358
- @mutex.synchronize { job[:team] = (job[:team] || {}).merge(result) } if command == 'mission'
359
- update(job, state, result: summarize(result))
360
- opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} #{state}\n#{summarize(result)}")
361
- result
362
- rescue JobControl::Stopped, PWN::AI::Agent::Solve::Control::Stopped => e
363
- update(job, 'cancelled', error: e.message)
364
- opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.message}")
365
- rescue StandardError => e
366
- update(job, 'failed', error: "#{e.class}: #{e.message}")
367
- opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.class}: #{e.message}")
440
+ result = nil
441
+ reader.each_line do |line|
442
+ tag, type, value = JSON.parse(line, symbolize_names: true)
443
+ next unless tag == id && job[:state] != 'cancelling'
444
+
445
+ restore_team_maps(value[:team]) if value.is_a?(Hash) && value[:team].is_a?(Hash)
446
+ case type
447
+ when 'complete' then result = value
448
+ when 'event' then on_event&.call(value[0].to_sym, value[1])
449
+ when 'notice' then on_event&.call(:notice, value)
450
+ when 'usage' then on_usage&.call(value)
451
+ when 'snapshot'
452
+ @mutex.synchronize { job[:team] = value[:team] if value[:team] }
453
+ job[:control].observed_pause = value[:paused]
454
+ when 'tool' then update(job, job[:state], last_tool: value)
455
+ end
456
+ end
457
+ ensure
458
+ job[:mission].close
459
+ reader.close
460
+ sender.close
461
+ unless job[:state] == 'cancelling'
462
+ result ||= { state: 'failed', error: 'mission process exited without a result' }
463
+ @mutex.synchronize { job[:team] = (job[:team] || {}).merge(result[:team]) } if result[:team]
464
+ update(job, result.fetch(:state), **result.slice(:result, :error))
465
+ on_event&.call(result[:error] ? :warning : :assistant, "#{agent} [#{id}] #{command} #{job[:state]}\n#{result[:result] || result[:error]}")
466
+ end
368
467
  end
369
468
  job[:worker].report_on_exception = false
370
- ready.pop
371
469
  { ok: true, job_id: id, state: 'queued' }
470
+ rescue StandardError => e
471
+ [reader, writer, commands, sender].compact.each { |io| io.close unless io.closed? }
472
+ job&.dig(:mission)&.close
473
+ update(job, 'failed', error: e.message) if job
474
+ { ok: false, error: "Mission not started: #{e.message}" }
475
+ end
476
+
477
+ # JSON object member names are strings on wire. Restore data maps whose
478
+ # keys are identifiers or filenames, rather than protocol field names.
479
+ def restore_team_maps(value)
480
+ case value
481
+ when Hash
482
+ value.each do |key, child|
483
+ value[key] = child = child.transform_keys(&:to_s) if %i[roles artifacts dependencies requirements inputs modes].include?(key) && child.is_a?(Hash)
484
+ restore_team_maps(child)
485
+ end
486
+ when Array
487
+ value.each { |child| restore_team_maps(child) }
488
+ end
489
+ value
372
490
  end
373
491
 
374
492
  def tool_callback(job, opts)
@@ -378,6 +496,7 @@ module PWN
378
496
  return
379
497
  end
380
498
  update(job, job[:state], last_tool: name)
499
+ @child_events << [:tool, name] if @child_events
381
500
  opts[:on_event]&.call(:tool, "#{job[:agent]} [#{job[:id]}] #{name}\n#{args.inspect}")
382
501
  opts[:on_event]&.call(:result, result.to_s)
383
502
  end
@@ -3906,17 +3906,9 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
3906
3906
  env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
3907
3907
  dec_path = dec_path.to_s.strip
3908
3908
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
3909
- return false unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
3909
+ return false unless File.exist?(env_path)
3910
3910
 
3911
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
3912
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
3913
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
3914
- return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
3915
-
3916
- PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
3917
- begin
3918
- cfg = YAML.load_file(env_path, symbolize_names: true)
3919
- cfg = {} unless cfg.is_a?(Hash)
3911
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
3920
3912
  cfg[:plugins] = {} unless cfg[:plugins].is_a?(Hash)
3921
3913
  dst = cfg[:plugins][:meshtastic]
3922
3914
  dst = {} unless dst.is_a?(Hash)
@@ -3933,15 +3925,10 @@ class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
3933
3925
  end
3934
3926
  end
3935
3927
  cfg[:plugins][:meshtastic] = dst
3936
- yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
3937
- File.write(env_path, yaml_env)
3938
- File.chmod(0o600, env_path)
3939
- ensure
3940
- PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
3941
3928
  end
3942
3929
  true
3943
3930
  rescue StandardError => e
3944
- mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}: #{e.message}")
3931
+ mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}")
3945
3932
  false
3946
3933
  end
3947
3934
 
@@ -23,14 +23,12 @@ module PWN
23
23
  # Prefer driver_opts (set by Config.refresh_env); fall back to the
24
24
  # canonical sidecar path Config uses: <pwn.yaml>.decryptor
25
25
  pwn_dec_path = PWN::Env[:driver_opts][:pwn_dec_path] ||= "#{pwn_env_path}.decryptor"
26
- unless File.exist?(pwn_dec_path)
26
+ unless PWN::Plugins::Vault.hardware_encrypted?(file: pwn_env_path) || File.exist?(pwn_dec_path)
27
27
  puts "ERROR: pwn decryptor file not found: #{pwn_dec_path}"
28
28
  return
29
29
  end
30
30
 
31
- decryptor = YAML.load_file(pwn_dec_path, symbolize_names: true)
32
- key = decryptor[:key]
33
- iv = decryptor[:iv]
31
+ access = PWN::Plugins::Vault.access_options(file: pwn_env_path, decryptor_file: pwn_dec_path)
34
32
 
35
33
  # Vault.edit decrypts -> opens editor -> encrypts and only sets
36
34
  # Pry.config.refresh_pwn_env = true. PWN::Config.refresh_env (which
@@ -40,19 +38,10 @@ module PWN
40
38
  # prompt "Press Enter to Resolve", wait on $stdin.gets, then re-open
41
39
  # the editor until the vault loads cleanly.
42
40
  loop do
43
- PWN::Plugins::Vault.edit(
44
- file: pwn_env_path,
45
- key: key,
46
- iv: iv
47
- )
41
+ PWN::Plugins::Vault.edit(access.merge(file: pwn_env_path))
48
42
 
49
43
  begin
50
- PWN::Config.refresh_env(
51
- pwn_env_path: pwn_env_path,
52
- pwn_dec_path: pwn_dec_path,
53
- key: key,
54
- iv: iv
55
- )
44
+ PWN::Config.refresh_env(access.merge(pwn_env_path: pwn_env_path, pwn_dec_path: pwn_dec_path))
56
45
  break
57
46
  rescue RuntimeError => e
58
47
  # Keep the prior in-memory Env usable if the operator aborts
@@ -642,222 +642,7 @@ module PWN
642
642
  # engines have a working .chat_with_tools and the native loop has had
643
643
  # real-API smoke time on each).
644
644
  # ----------------------------------------------------------------
645
- # Do NOT rebind the 'request' parameter (the string object passed by Pry's after_read hook).
646
- # We will mutate it to 'nil' at the end of handling so Pry does not eval the natural-language
647
- # prompt text as Ruby (which was causing noisy exceptions *after* the green AI response print).
648
- debug = pi.config.pwn_ai_debug
649
- engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
650
- response_history = PWN::Env[:ai][engine][:response_history]
651
- speak_answer = pi.config.pwn_ai_speak
652
- is_agent = (pi.config.pwn_ai_agent == true)
653
-
654
- # pwn-ai agent mode: load skills context for autonomous task carrying
655
- skills_context = ''
656
- PWN::Skills.each { |n, m| skills_context += "\n--- SKILL #{n} ---\n#{m[:content].to_s[0, 1200]}\n" } if is_agent && PWN.const_defined?(:Skills) && PWN::Skills.is_a?(Hash)
657
-
658
- memory_context = ''
659
- memory_context = PWN::Memory.to_context(limit: 25) if is_agent && PWN.const_defined?(:Memory)
660
-
661
- sess_id = begin
662
- pi.config.pwn_ai_session_id
663
- rescue StandardError
664
- nil
665
- end
666
-
667
- # Pre-process for clear CLI execution intent (e.g. "what does `id` return?")
668
- # This makes the agent actually *run* commands instead of just explaining them.
669
- curr_req = orig_request.chomp
670
- if is_agent && sess_id && PWN.const_defined?(:Sessions)
671
- begin
672
- PWN::Sessions.append(session_id: sess_id, role: 'user', content: orig_request)
673
- rescue StandardError
674
- nil
675
- end
676
- end
677
- if is_agent && request =~ /`([^`]+)`/
678
- potential = ::Regexp.last_match(1).strip
679
- # Looks like a shell command (not PWN ruby)
680
- unless potential =~ /^(PWN::|def |class |require |puts |pp )/
681
- curr_req = "The user wants the *actual raw output* of this command (do not just describe it): `#{potential}`. " \
682
- 'To fulfill the request accurately, you MUST immediately output ONLY a bash code block with the exact command. ' \
683
- "Example format: ```bash\n#{potential}\n``` . After the host executes it, you will receive the OBSERVATION with the real output."
684
- end
685
- end
686
-
687
- # Strict system prompt for agent mode (forces tool use over explanation)
688
- system_role = nil
689
- if is_agent
690
- base = PWN::Env[:ai][engine][:system_role_content] || 'You are an ethical hacker.'
691
- system_role = base + <<~PROMPT
692
-
693
- You are operating as an autonomous agent inside the PWN REPL driver.
694
-
695
- PRIMARY RULE FOR CLI AND TOOLS: When the user asks for the output of a command, "what does X return?", "run X", or anything that requires real execution, you MUST use a tool call.#{' '}
696
- NEVER just explain what a command does or what its output "would be".#{' '}
697
- To execute anything:
698
- - Output *exactly and only* a fenced code block.
699
- - For shell/CLI: ```bash
700
- <exact command here>
701
- ```
702
- - For PWN Ruby modules: ```ruby
703
- PWN::Plugins::NmapIt.port_scan(...)
704
- ```
705
- The host will execute it (Ruby in full PWN context, bash via shell) and reply with an OBSERVATION containing the real result.#{' '}
706
- Then continue or give the final answer.
707
-
708
- Available tools include all PWN::Plugins (NmapIt, TransparentBrowser, etc.), SAST, Reports, and any CLI via bash blocks.
709
- Skills available this session:#{skills_context}
710
- #{memory_context}
711
-
712
- PERSISTENT CAPABILITIES (use via ruby code blocks or direct calls):
713
- - Memory (cross-session): PWN::Memory.remember(key: :key, value: val, category: :fact|:preference|:lesson)
714
- PWN::Memory.recall(query: 'foo'), PWN::Memory.forget(key: key)
715
- - Sessions: current session id = #{sess_id}; PWN::Sessions.append(session_id: '#{sess_id}', role: 'observation', content: obs)
716
- - Cron: PWN::Cron.create(schedule: '0 * * * *', prompt: 'task here', name: 'foo')
717
- PWN::Cron.run(id: 'id'); list with PWN::Cron.list
718
- - Agents/Delegation: PWN::AI::Agent::SAST.analyze(request: ...); PWN::AI::Agent::VulnGen etc.
719
- For sub-agents use threads or separate eval calls and feed results back as OBS.
720
-
721
- After receiving an observation, decide the next step or conclude.
722
- If you output text without a code block, it will be treated as your final answer to the user.
723
- PROMPT
724
- end
725
-
726
- max_turns = is_agent ? 7 : 1
727
- turn = 0
728
- last_response = ''
729
- tool_was_executed_this_turn = false
730
-
731
- while turn < max_turns
732
- chat_opts = {
733
- request: curr_req,
734
- response_history: response_history,
735
- speak_answer: speak_answer,
736
- spinner: false
737
- }
738
- chat_opts[:system_role_content] = system_role if system_role
739
-
740
- case engine
741
- when :anthropic
742
- response = PWN::AI::Anthropic.chat(chat_opts)
743
- when :gemini
744
- response = PWN::AI::Gemini.chat(chat_opts)
745
- when :grok
746
- response = PWN::AI::Grok.chat(chat_opts)
747
- when :ollama
748
- response = PWN::AI::Ollama.chat(chat_opts)
749
- when :openai
750
- response = PWN::AI::OpenAI.chat(chat_opts)
751
- when :openwebui
752
- response = PWN::AI::OpenWebUI.chat(chat_opts)
753
- else
754
- raise "ERROR: Unsupported AI Engine: #{engine}"
755
- end
756
-
757
- if response.nil?
758
- last_response = 'Model not currently supported with API key.'
759
- else
760
- if response[:choices].last.keys.include?(:text)
761
- last_response = response[:choices].last[:text].to_s
762
- else
763
- last_response = response[:choices].last[:content].to_s
764
- end
765
- response_history = {
766
- id: response[:id],
767
- object: response[:object],
768
- model: response[:model],
769
- usage: response[:usage]
770
- }
771
- response_history[:choices] ||= response[:choices]
772
- end
773
-
774
- puts "\n\001\e[32m\002#{last_response}\001\e[0m\002\n\n"
775
- if is_agent && sess_id && PWN.const_defined?(:Sessions)
776
- begin
777
- PWN::Sessions.append(session_id: sess_id, role: 'assistant', content: last_response)
778
- rescue StandardError
779
- nil
780
- end
781
- end
782
-
783
- if debug
784
- puts 'DEBUG: response_history => '
785
- pp response_history
786
- end
787
- PWN::Env[:ai][engine][:response_history] = response_history
788
-
789
- # === Agent tool execution: parse code blocks from *this* response and actually run them ===
790
- tool_was_executed_this_turn = false
791
- if is_agent
792
- # Robust regex: tolerate language specifier, extra whitespace, and text around the block
793
- last_response.scan(/```(?:\s*(ruby|bash|sh|shell|zsh))?\s*\n?(.*?)\n?```/m).each do |lang, code|
794
- code = code.strip
795
- next if code.empty? || tool_was_executed_this_turn
796
-
797
- lang = (lang || 'bash').downcase
798
- puts "\001\e[33m\002[ pwn-ai AGENT EXEC #{lang} ]\e[0m\002 #{code[0..90]}..."
799
-
800
- obs = ''
801
- begin
802
- if lang == 'ruby'
803
- require 'stringio'
804
- old_stdout = $stdout
805
- $stdout = StringIO.new
806
- res = eval(code, TOPLEVEL_BINDING) # rubocop:disable Security/Eval -- intentional for pwn-ai agent to run PWN Ruby modules/tools in REPL context
807
- captured = $stdout.string
808
- $stdout = old_stdout
809
- obs = (captured + "\n=> #{res.inspect}").strip
810
- else
811
- # CLI execution - use Open3 for cleaner capture (no extra shell if possible, but backticks are simple and work)
812
- require 'open3'
813
- stdout, stderr, status = Open3.capture3(code)
814
- obs = stdout
815
- obs += "\n[stderr]\n#{stderr}" unless stderr.to_s.strip.empty?
816
- obs += "\n[exit: #{status.exitstatus}]" unless status.success?
817
- obs = obs.strip
818
- end
819
- rescue StandardError => e
820
- obs = "ERROR executing #{lang} block: #{e.class} - #{e.message}"
821
- end
822
-
823
- puts "\001\e[36m\002[OBSERVATION from #{lang}]\001\e[0m\002\n#{obs[0..700]}\n"
824
- if is_agent && sess_id && PWN.const_defined?(:Sessions)
825
- begin
826
- PWN::Sessions.append(session_id: sess_id, role: 'observation', content: obs)
827
- rescue StandardError
828
- nil
829
- end
830
- end
831
-
832
- # Feed real result back to the model as the next "user" message in the loop
833
- curr_req = "OBSERVATION (#{lang} execution result for previous block):\n#{obs}\n\n" \
834
- "Now continue fulfilling the original user request: #{orig_request}. " \
835
- 'If the task is complete, give the final answer (no more code blocks). Otherwise output the next needed tool block.'
836
-
837
- tool_was_executed_this_turn = true
838
- turn += 1
839
- break # one execution per model turn for controlled pacing
840
- end
841
- end
842
-
843
- # If we executed something, loop to let the model react to the OBS
844
- next if tool_was_executed_this_turn
845
-
846
- # No tool executed this turn -> this last_response is the final answer
847
- break
848
- end
849
-
850
- # If in agent mode and the model never produced an executable block but the query clearly wanted execution,
851
- # give one last chance with a strong reminder (helps weaker models like some Ollama ones)
852
- if is_agent && !tool_was_executed_this_turn && orig_request =~ /`[^`]+`/ && turn < max_turns
853
- reminder = 'The user explicitly asked about the output of a command in backticks. ' \
854
- 'Do not describe the command. Output *only* the corresponding ```bash block now so the host can run it and give you the real result.'
855
- curr_req = "#{reminder}\nOriginal: #{orig_request}"
856
- # One final direct call (no full re-loop to avoid complexity)
857
- # (The main loop already handled most cases; this is a safety net)
858
- end
859
- request.replace('nil') if request.respond_to?(:replace)
860
- PWN::Plugins::REPL.ready_tty!
645
+ PWN::Plugins::REPL.pwn_ai_run_legacy(request: request, original_request: orig_request, pry: pi)
861
646
  end
862
647
  end
863
648
 
@@ -155,6 +155,10 @@ module PWNSandboxDriver
155
155
  command = ['bwrap', '--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv', '--setenv', 'PATH', '/usr/bin:/bin', '--ro-bind', '/usr', '/usr']
156
156
  %w[/lib /lib64 /bin /sbin].each { |name| command.push('--ro-bind', name, name) if File.exist?(name) }
157
157
  command.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--ro-bind', artifacts, '/artifacts', '--chdir', '/tmp', File.realpath('/usr/bin/ruby'), '/artifacts/worker.rb', '--inside')
158
+ # A nonroot caller inside a user namespace may retain capabilities;
159
+ # bubblewrap rejects those before creating its own namespace.
160
+ effective = File.read('/proc/self/status')[/^CapEff:\s+(\h+)/, 1].to_i(16)
161
+ command.unshift('setpriv', '--ambient-caps=-all', '--inh-caps=-all', '--bounding-set=-all', '--') if !Process.uid.zero? && effective.positive?
158
162
  end
159
163
  begin
160
164
  result = bounded(command, (seconds * 3) + 10, JSON.generate(payload))