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
@@ -250,7 +250,7 @@ module PWN
250
250
  end
251
251
  end
252
252
  PWN_AI_SLASH_COMMANDS = %w[
253
- /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace /vault
253
+ /back /cron /debug /delegate /help /learning /maintenance /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace /vault
254
254
  ].freeze
255
255
 
256
256
  PWN_AI_SLASH_SUBCOMMANDS = {
@@ -265,6 +265,7 @@ module PWN
265
265
  '/model' => %w[list],
266
266
  '/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
267
267
  '/sessions' => %w[list resume delete stats cleanup],
268
+ '/maintenance' => %w[status run dry-run],
268
269
 
269
270
  '/skills' => %w[list recall],
270
271
  '/learning' => %w[list requeue]
@@ -277,9 +278,294 @@ module PWN
277
278
  # 2. '/' anywhere else → :path (host-native file nav)
278
279
  # 3. else → :ruby (same Pry::InputCompleter menu as the pwn REPL)
279
280
  class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
280
- # Foreground Loop plus one scoped terminal reader, not another Pry
281
- # readline and not a worker thread running tools without their locals.
281
+ # The terminal owner survives CTRL+C; only its mission tree is killed.
282
282
  def pwn_ai_run_steerable(opts = {})
283
+ pwn_ai_run_mission(opts.merge(operation: :steerable))
284
+ end
285
+
286
+ # Both native tools and regex-ReAct must execute below the same owner.
287
+ # Never retry a failed mission in the terminal/controller process.
288
+ def pwn_ai_run_legacy(opts = {})
289
+ result = pwn_ai_run_mission(opts.merge(operation: :legacy))
290
+ PWN::Env[:ai][result.fetch(:engine).to_sym][:response_history] = result[:response_history]
291
+ rescue StandardError => e
292
+ warn "[pwn-ai] legacy agent mission failed (#{e.class}: #{e.message.to_s.split("\n").first}); no uncontained retry was attempted"
293
+ ensure
294
+ opts[:request]&.replace('nil')
295
+ PWN::Plugins::REPL.ready_tty!
296
+ end
297
+
298
+ def pwn_ai_run_mission(opts = {})
299
+ require 'io/console'
300
+ input = opts[:input] || $stdin
301
+ reader, writer = IO.pipe
302
+ run = proc do
303
+ config = %i[pwn_ai_session_id pwn_ai_debug pwn_ai_trace pwn_ai_speak pwn_ai_agent].to_h { |key| [key, opts[:pry]&.config&.public_send(key)] }
304
+ descriptor = { operation: opts.fetch(:operation), config: config, options: opts.except(:pry, :input, :on_tool, :steering, :debug_tee) }
305
+ mission = PWN::Plugins::AISandbox::MissionProcess.new(descriptor: descriptor, fds: { 4 => writer, 0 => input })
306
+ writer.close
307
+ envelope = {}
308
+ reader.each_line do |line|
309
+ frame = JSON.parse(line, symbolize_names: true)
310
+ if frame[:event] == 'tool'
311
+ opts[:on_tool]&.call(*frame[:args])
312
+ else
313
+ envelope = frame
314
+ end
315
+ end
316
+ raise IOError, envelope[:error] if envelope[:error]
317
+
318
+ envelope[:result]
319
+ ensure
320
+ mission&.close
321
+ end
322
+ input.tty? ? input.cooked(&run) : run.call
323
+ ensure
324
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
325
+ end
326
+
327
+ def pwn_ai_mission_entry(opts = {})
328
+ pry = Pry.new
329
+ opts.fetch(:config).each { |key, value| pry.config.public_send("#{key}=", value) }
330
+ writer = PWN::AI::Agent::MissionNamespace.io(4)
331
+ options = { trace: pry.config.pwn_ai_trace }.merge(opts.fetch(:options)).merge(
332
+ pry: pry, input: PWN::AI::Agent::MissionNamespace.io(0),
333
+ on_tool: ->(*args) { writer.puts(JSON.generate(event: 'tool', args: args)) }
334
+ )
335
+ begin
336
+ result = opts[:operation] == :legacy ? pwn_ai_run_legacy_child(options) : pwn_ai_run_steerable_child(options)
337
+ writer.puts(JSON.generate(result: result))
338
+ rescue StandardError => e
339
+ writer.puts(JSON.generate(error: "#{e.class}: #{e.message}"))
340
+ ensure
341
+ writer.close
342
+ end
343
+ end
344
+
345
+ def pwn_ai_run_legacy_child(opts = {})
346
+ request = opts[:request]
347
+ orig_request = opts[:original_request]
348
+ pi = opts[:pry]
349
+ # Do NOT rebind the 'request' parameter (the string object passed by Pry's after_read hook).
350
+ # We will mutate it to 'nil' at the end of handling so Pry does not eval the natural-language
351
+ # prompt text as Ruby (which was causing noisy exceptions *after* the green AI response print).
352
+ debug = pi.config.pwn_ai_debug
353
+ engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
354
+ response_history = PWN::Env[:ai][engine][:response_history]
355
+ speak_answer = pi.config.pwn_ai_speak
356
+ is_agent = (pi.config.pwn_ai_agent == true)
357
+
358
+ # pwn-ai agent mode: load skills context for autonomous task carrying
359
+ skills_context = ''
360
+ 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)
361
+
362
+ memory_context = ''
363
+ memory_context = PWN::Memory.to_context(limit: 25) if is_agent && PWN.const_defined?(:Memory)
364
+
365
+ sess_id = begin
366
+ pi.config.pwn_ai_session_id
367
+ rescue StandardError
368
+ nil
369
+ end
370
+
371
+ # Pre-process for clear CLI execution intent (e.g. "what does `id` return?")
372
+ # This makes the agent actually *run* commands instead of just explaining them.
373
+ curr_req = orig_request.chomp
374
+ if is_agent && sess_id && PWN.const_defined?(:Sessions)
375
+ begin
376
+ PWN::Sessions.append(session_id: sess_id, role: 'user', content: orig_request)
377
+ rescue StandardError
378
+ nil
379
+ end
380
+ end
381
+ if is_agent && request =~ /`([^`]+)`/
382
+ potential = ::Regexp.last_match(1).strip
383
+ # Looks like a shell command (not PWN ruby)
384
+ unless potential =~ /^(PWN::|def |class |require |puts |pp )/
385
+ curr_req = "The user wants the *actual raw output* of this command (do not just describe it): `#{potential}`. " \
386
+ 'To fulfill the request accurately, you MUST immediately output ONLY a bash code block with the exact command. ' \
387
+ "Example format: ```bash\n#{potential}\n``` . After the host executes it, you will receive the OBSERVATION with the real output."
388
+ end
389
+ end
390
+
391
+ # Strict system prompt for agent mode (forces tool use over explanation)
392
+ system_role = nil
393
+ if is_agent
394
+ base = PWN::Env[:ai][engine][:system_role_content] || 'You are an ethical hacker.'
395
+ system_role = base + <<~PROMPT
396
+
397
+ You are operating as an autonomous agent inside the PWN REPL driver.
398
+
399
+ 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.#{' '}
400
+ NEVER just explain what a command does or what its output "would be".#{' '}
401
+ To execute anything:
402
+ - Output *exactly and only* a fenced code block.
403
+ - For shell/CLI: ```bash
404
+ <exact command here>
405
+ ```
406
+ - For PWN Ruby modules: ```ruby
407
+ PWN::Plugins::NmapIt.port_scan(...)
408
+ ```
409
+ The host will execute it (Ruby in full PWN context, bash via shell) and reply with an OBSERVATION containing the real result.#{' '}
410
+ Then continue or give the final answer.
411
+
412
+ Available tools include all PWN::Plugins (NmapIt, TransparentBrowser, etc.), SAST, Reports, and any CLI via bash blocks.
413
+ Skills available this session:#{skills_context}
414
+ #{memory_context}
415
+
416
+ PERSISTENT CAPABILITIES (use via ruby code blocks or direct calls):
417
+ - Memory (cross-session): PWN::Memory.remember(key: :key, value: val, category: :fact|:preference|:lesson)
418
+ PWN::Memory.recall(query: 'foo'), PWN::Memory.forget(key: key)
419
+ - Sessions: current session id = #{sess_id}; PWN::Sessions.append(session_id: '#{sess_id}', role: 'observation', content: obs)
420
+ - Cron: PWN::Cron.create(schedule: '0 * * * *', prompt: 'task here', name: 'foo')
421
+ PWN::Cron.run(id: 'id'); list with PWN::Cron.list
422
+ - Agents/Delegation: PWN::AI::Agent::SAST.analyze(request: ...); PWN::AI::Agent::VulnGen etc.
423
+ For sub-agents use threads or separate eval calls and feed results back as OBS.
424
+
425
+ After receiving an observation, decide the next step or conclude.
426
+ If you output text without a code block, it will be treated as your final answer to the user.
427
+ PROMPT
428
+ end
429
+
430
+ max_turns = is_agent ? 7 : 1
431
+ turn = 0
432
+ last_response = ''
433
+ tool_was_executed_this_turn = false
434
+
435
+ while turn < max_turns
436
+ chat_opts = {
437
+ request: curr_req,
438
+ response_history: response_history,
439
+ speak_answer: speak_answer,
440
+ spinner: false
441
+ }
442
+ chat_opts[:system_role_content] = system_role if system_role
443
+
444
+ case engine
445
+ when :anthropic
446
+ response = PWN::AI::Anthropic.chat(chat_opts)
447
+ when :gemini
448
+ response = PWN::AI::Gemini.chat(chat_opts)
449
+ when :grok
450
+ response = PWN::AI::Grok.chat(chat_opts)
451
+ when :ollama
452
+ response = PWN::AI::Ollama.chat(chat_opts)
453
+ when :openai
454
+ response = PWN::AI::OpenAI.chat(chat_opts)
455
+ when :openwebui
456
+ response = PWN::AI::OpenWebUI.chat(chat_opts)
457
+ else
458
+ raise "ERROR: Unsupported AI Engine: #{engine}"
459
+ end
460
+
461
+ if response.nil?
462
+ last_response = 'Model not currently supported with API key.'
463
+ else
464
+ if response[:choices].last.keys.include?(:text)
465
+ last_response = response[:choices].last[:text].to_s
466
+ else
467
+ last_response = response[:choices].last[:content].to_s
468
+ end
469
+ response_history = {
470
+ id: response[:id],
471
+ object: response[:object],
472
+ model: response[:model],
473
+ usage: response[:usage]
474
+ }
475
+ response_history[:choices] ||= response[:choices]
476
+ end
477
+
478
+ puts "\n\001\e[32m\002#{last_response}\001\e[0m\002\n\n"
479
+ if is_agent && sess_id && PWN.const_defined?(:Sessions)
480
+ begin
481
+ PWN::Sessions.append(session_id: sess_id, role: 'assistant', content: last_response)
482
+ rescue StandardError
483
+ nil
484
+ end
485
+ end
486
+
487
+ if debug
488
+ puts 'DEBUG: response_history => '
489
+ pp response_history
490
+ end
491
+ PWN::Env[:ai][engine][:response_history] = response_history
492
+
493
+ # === Agent tool execution: parse code blocks from *this* response and actually run them ===
494
+ tool_was_executed_this_turn = false
495
+ if is_agent
496
+ # Robust regex: tolerate language specifier, extra whitespace, and text around the block
497
+ last_response.scan(/```(?:\s*(ruby|bash|sh|shell|zsh))?\s*\n?(.*?)\n?```/m).each do |lang, code|
498
+ code = code.strip
499
+ next if code.empty? || tool_was_executed_this_turn
500
+
501
+ lang = (lang || 'bash').downcase
502
+ puts "\001\e[33m\002[ pwn-ai AGENT EXEC #{lang} ]\e[0m\002 #{code[0..90]}..."
503
+
504
+ obs = ''
505
+ begin
506
+ obs = PWN::Plugins::AISandbox.protect_call do
507
+ if lang == 'ruby'
508
+ require 'stringio'
509
+ old_stdout = $stdout
510
+ $stdout = StringIO.new
511
+ res = eval(code, TOPLEVEL_BINDING) # rubocop:disable Security/Eval -- intentional sandboxed legacy tool evaluation
512
+ captured = $stdout.string
513
+ $stdout = old_stdout
514
+ (captured + "\n=> #{res.inspect}").strip
515
+ else
516
+ require 'open3'
517
+ stdout, stderr, status = Open3.capture3(code)
518
+ output = stdout
519
+ output += "\n[stderr]\n#{stderr}" unless stderr.to_s.strip.empty?
520
+ output += "\n[exit: #{status.exitstatus}]" unless status.success?
521
+ output.strip
522
+ end
523
+ end
524
+ obs = JSON.generate(obs) unless obs.is_a?(String)
525
+ rescue StandardError => e
526
+ obs = "ERROR executing #{lang} block: #{e.class} - #{e.message}"
527
+ end
528
+
529
+ puts "\001\e[36m\002[OBSERVATION from #{lang}]\001\e[0m\002\n#{obs[0..700]}\n"
530
+ if is_agent && sess_id && PWN.const_defined?(:Sessions)
531
+ begin
532
+ PWN::Sessions.append(session_id: sess_id, role: 'observation', content: obs)
533
+ rescue StandardError
534
+ nil
535
+ end
536
+ end
537
+
538
+ # Feed real result back to the model as the next "user" message in the loop
539
+ curr_req = "OBSERVATION (#{lang} execution result for previous block):\n#{obs}\n\n" \
540
+ "Now continue fulfilling the original user request: #{orig_request}. " \
541
+ 'If the task is complete, give the final answer (no more code blocks). Otherwise output the next needed tool block.'
542
+
543
+ tool_was_executed_this_turn = true
544
+ turn += 1
545
+ break # one execution per model turn for controlled pacing
546
+ end
547
+ end
548
+
549
+ # If we executed something, loop to let the model react to the OBS
550
+ next if tool_was_executed_this_turn
551
+
552
+ # No tool executed this turn -> this last_response is the final answer
553
+ break
554
+ end
555
+
556
+ # If in agent mode and the model never produced an executable block but the query clearly wanted execution,
557
+ # give one last chance with a strong reminder (helps weaker models like some Ollama ones)
558
+ if is_agent && !tool_was_executed_this_turn && orig_request =~ /`[^`]+`/ && turn < max_turns
559
+ reminder = 'The user explicitly asked about the output of a command in backticks. ' \
560
+ 'Do not describe the command. Output *only* the corresponding ```bash block now so the host can run it and give you the real result.'
561
+ curr_req = "#{reminder}\nOriginal: #{orig_request}"
562
+ # One final direct call (no full re-loop to avoid complexity)
563
+ # (The main loop already handled most cases; this is a safety net)
564
+ end
565
+ { engine: engine, response_history: response_history }
566
+ end
567
+
568
+ def pwn_ai_run_steerable_child(opts = {})
283
569
  previous = Thread.current[:pwn_steering_input]
284
570
  workspace = File.realpath(Dir.pwd)
285
571
  lock_path = File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock")
@@ -580,6 +866,16 @@ module PWN
580
866
  end
581
867
  when '/cron'
582
868
  pwn_ai_run_cron(args: args)
869
+ when '/maintenance'
870
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
871
+ raise ArgumentError, 'usage: /maintenance [status|run|dry-run]' unless args.empty? || (args.length == 1 && %w[status run dry-run].include?(args.first))
872
+
873
+ result = if args.empty? || args.first == 'status'
874
+ PWN::SessionCleanup.status
875
+ else
876
+ PWN::SessionCleanup.maintain(dry_run: args.first == 'dry-run', current_session_id: pi&.config&.pwn_ai_session_id)
877
+ end
878
+ pwn_ai_print_result(value: result)
583
879
  when '/sessions'
584
880
  identifier = request.sub(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]+|$)}, '') if args.first == 'resume'
585
881
  pwn_ai_run_sessions(args: args, identifier: identifier, pry: pi)
@@ -851,24 +1147,11 @@ module PWN
851
1147
  env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
852
1148
  dec_path = dec_path.to_s.strip
853
1149
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
854
- return false unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
855
-
856
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
857
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
858
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
859
- return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
860
-
861
- # Never decrypt the live vault in place: failed encryption must leave
862
- # the previous ciphertext intact. Rename only the encrypted merge.
863
- require 'tempfile'
864
- Tempfile.create(['.pwn-ai-selection', '.yaml'], File.dirname(env_path)) do |file|
865
- file.binmode
866
- file.write(File.binread(env_path))
867
- file.flush
868
- path = file.path
869
- PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
870
- cfg = YAML.load_file(path, symbolize_names: true)
871
- cfg = {} unless cfg.is_a?(Hash)
1150
+ return false unless File.exist?(env_path)
1151
+
1152
+ # Shared update preserves hardware or legacy format and publishes only
1153
+ # ciphertext after a locked re-read and final conflict comparison.
1154
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
872
1155
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
873
1156
  cfg[:ai][:active] = engine unless opts.key?(:system_role_content)
874
1157
  unless model.to_s.strip.empty?
@@ -884,15 +1167,10 @@ module PWN
884
1167
  cfg[:ai][engine.to_sym] ||= {}
885
1168
  cfg[:ai][engine.to_sym][:system_role_content] = opts[:system_role_content]
886
1169
  end
887
- yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
888
- File.write(path, yaml_env)
889
- File.chmod(0o600, path)
890
- PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
891
- File.rename(path, env_path)
892
1170
  end
893
1171
  true
894
1172
  rescue StandardError => e
895
- warn "[pwn-ai] settings persist failed: #{e.class}: #{e.message}"
1173
+ warn "[pwn-ai] settings persist failed: #{e.class}; check vault access and retry."
896
1174
  false
897
1175
  end
898
1176