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
@@ -4,6 +4,35 @@ require 'spec_helper'
4
4
  require 'stringio'
5
5
  require 'pry'
6
6
 
7
+ describe 'mission configuration handoff' do
8
+ include_context 'pwn tmp sandbox'
9
+
10
+ it 'forwards the operator trace setting to console and line-mode mission loops' do
11
+ input, sender = IO.pipe
12
+ pry = Pry.new
13
+ pry.config.pwn_ai_trace = true
14
+ pry.config.pwn_ai_session_id = 'trace-handoff'
15
+ allow(PWN::Sessions).to receive(:provisional_title).and_return(nil)
16
+ allow(PWN::Sessions).to receive(:refresh_title).and_return(nil)
17
+ allow(PWN::AI::Agent::Loop).to receive(:run) { |opts| opts[:trace] ? 'trace forwarded' : 'trace missing' }
18
+ controller = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: input, curses: nil, getch: nil)
19
+ Dir.chdir(@tmp) do
20
+ controller.start_request('fixture')
21
+ Timeout.timeout(10) do
22
+ while controller.busy?
23
+ controller.drain
24
+ sleep 0.01
25
+ end
26
+ end
27
+ expect(controller.instance_variable_get(:@timeline).map { |row| row[1] }).to include('trace forwarded')
28
+ expect(PWN::Plugins::REPL.pwn_ai_run_steerable(request: 'fixture', pry: pry, input: input)).to eq('trace forwarded')
29
+ end
30
+ ensure
31
+ controller&.stop_worker
32
+ [input, sender].compact.each { |io| io.close unless io.closed? }
33
+ end
34
+ end
35
+
7
36
  describe 'operator workspace navigation' do
8
37
  include_context 'pwn tmp sandbox'
9
38
 
@@ -276,16 +305,16 @@ describe 'session maintenance command workers' do
276
305
 
277
306
  after { console.send(:stop_worker) }
278
307
 
279
- it 'routes cleanup off the input owner and delivers completion after safe cancellation' do
308
+ it 'routes cleanup off the input owner and hard cancels before returning ready' do
280
309
  require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
281
- entered = Queue.new
282
- release = Queue.new
283
- owner = Thread.current
310
+ entered = PWNTestPipeQueue.new
311
+ release = PWNTestPipeQueue.new
312
+ owner = Process.pid
284
313
  reader, writer = IO.pipe
285
314
  console.instance_variable_set(:@input, reader)
286
315
  console.instance_variable_set(:@tool_input, writer)
287
316
  allow(PWN::SessionCleanup).to receive(:maintain) do |**options|
288
- entered << [Thread.current, options]
317
+ entered << [Process.pid, options]
289
318
  release.pop
290
319
  { fixture: 'committed deletion' }
291
320
  end
@@ -294,21 +323,20 @@ describe 'session maintenance command workers' do
294
323
  console.instance_variable_get(:@events) << [:command, value.inspect]
295
324
  end
296
325
  Timeout.timeout(2) { console.submit('/sessions cleanup') }
297
- thread, options = Timeout.timeout(2) { entered.pop }
298
- expect(thread).not_to eq(owner)
326
+ pid, options = Timeout.timeout(2) { entered.pop }
327
+ expect(pid).not_to eq(owner)
299
328
  expect(options[:dry_run]).to be(false)
300
329
  console.handle("\u0014")
301
330
  console.handle(:page_up)
302
331
  console.submit('/sessions cleanup')
303
- expect(PWN::SessionCleanup).to have_received(:maintain).once
332
+ expect(entered).to be_empty
304
333
  console.handle("\u0003")
305
- expect(thread).to be_alive
306
- release << true
307
- expect(thread.join(2)).to eq(thread)
334
+ expect(console.instance_variable_get(:@worker)).not_to be_alive
308
335
  console.send(:drain)
309
336
  expect(console.send(:busy?)).to be_falsey
310
337
  text = console.instance_variable_get(:@timeline).map { |row| row[1] }.join
311
- expect(text).to include('committed deletion', 'not undone')
338
+ expect(text).to include('not undone')
339
+ expect(text).not_to include('committed deletion')
312
340
  expect(text).not_to include('Request cancelled')
313
341
  ensure
314
342
  release << true if release
@@ -325,16 +353,12 @@ describe 'session maintenance command workers' do
325
353
  reader, writer = IO.pipe
326
354
  console.instance_variable_set(:@input, reader)
327
355
  console.instance_variable_set(:@tool_input, writer)
328
- events = console.instance_variable_get(:@events)
329
- output = PWN::Plugins::REPL::AIConsole::EventIO.new(events)
330
- allow(PWN::Plugins::REPL).to receive(:pwn_ai_print_result).and_wrap_original do |method, **options|
331
- method.call(options.merge(output: output))
332
- end
333
356
  lock = PWN::AI::Agent::Learning.send(:learning_lock)
334
- entered = Queue.new
335
- allow(PWN::AI::Agent::Learning).to receive(:migrate_evidence).and_wrap_original do |method, **options|
357
+ entered = PWNTestPipeQueue.new
358
+ # Busy Learning now defers at maintenance admission, before migration.
359
+ allow(PWN::SessionCleanup).to receive(:maintenance_transaction).and_wrap_original do |method, **options|
336
360
  entered << true
337
- method.call(options)
361
+ method.call(**options)
338
362
  end
339
363
  Timeout.timeout(2) { console.submit('/sessions cleanup') }
340
364
  Timeout.timeout(2) { entered.pop }
@@ -387,10 +411,10 @@ describe 'session maintenance command workers' do
387
411
  reader, writer = IO.pipe
388
412
  console.instance_variable_set(:@input, reader)
389
413
  console.instance_variable_set(:@tool_input, writer)
390
- entered = Queue.new
391
- release = Queue.new
414
+ entered = PWNTestPipeQueue.new
415
+ release = PWNTestPipeQueue.new
392
416
  # Pause inside the admitted deletion transaction, not before run checks stop.
393
- # Cancellation here must finish the backed-up commit without deleting late IDs.
417
+ # Let the approved transaction complete; hard cancellation is tested above.
394
418
  allow(PWN::Sessions).to receive(:delete).and_wrap_original do |method, **options|
395
419
  entered << true
396
420
  release.pop
@@ -399,7 +423,6 @@ describe 'session maintenance command workers' do
399
423
  Timeout.timeout(2) { console.handle("\r") }
400
424
  Timeout.timeout(2) { entered.pop }
401
425
  console.handle("\e")
402
- console.handle("\u0003")
403
426
  release << true
404
427
  expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
405
428
  console.send(:drain)
@@ -561,14 +584,14 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
561
584
  end
562
585
 
563
586
  it 'paints provisional and asynchronously updated summary titles in the actual SESSION pane' do
564
- reached = Queue.new
565
- release = Queue.new
566
- owner = Thread.current
587
+ reached = PWNTestPipeQueue.new
588
+ release = PWNTestPipeQueue.new
589
+ owner = Process.pid
567
590
  PWN::Env[:ai] = { active: :openai, openai: { model: 'active-title-model', key: 'fixture-key' } }
568
591
  allow(PWN::AI::Router).to receive(:summarize).and_call_original
569
592
  expect(PWN::AI::Ollama).not_to receive(:chat)
570
- expect(PWN::AI::OpenAI).to receive(:chat).with(hash_including(model: 'active-title-model', request: a_string_including('overall conversation', 'at most 7 words'))).once do
571
- expect(Thread.current).not_to eq(owner)
593
+ allow(PWN::AI::OpenAI).to receive(:chat).with(hash_including(model: 'active-title-model', request: a_string_including('overall conversation', 'at most 7 words'))) do
594
+ expect(Process.pid).not_to eq(owner)
572
595
  reached << true
573
596
  release.pop
574
597
  JSON.generate(title: 'Login and certificate repair plus automated rotation verification', summary: 'Investigated login and repaired certificates.')
@@ -590,13 +613,13 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
590
613
  @pry.config.pwn_ai_session_id = PWN::Sessions.create[:id]
591
614
  next "\r"
592
615
  end
593
- if !reached.empty? && !listed
616
+ # The provider barrier can precede the IPC reader's next rendered frame.
617
+ provisional_painted = @paint.join.include?('SESSION Investigate login and repair all expired certificates')
618
+ if !reached.empty? && !listed && provisional_painted
594
619
  listed = true
595
- keys.concat("/sessions list\n".chars)
596
- end
597
- if listed && @rendered.map(&:last).join.include?('active: true') && !unblocked
598
620
  expect(@paint.join).to include('SESSION Investigate login and repair all expired certificates')
599
- expect(@rendered.map(&:last).join).to include(@pry.config.pwn_ai_session_id, 'title: Investigate login and repair all expired certificates')
621
+ end
622
+ if listed && !unblocked
600
623
  expect(PWN::Sessions.title_status(session_id: @pry.config.pwn_ai_session_id)[:is_summarized]).to be(false)
601
624
  unblocked = true
602
625
  release << true
@@ -619,7 +642,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
619
642
  requested = false
620
643
  selected = false
621
644
  keys = "/sessions list\n".chars
622
- expect(PWN::AI::Agent::Loop).to receive(:run).with(hash_including(request: 'Continue fixture', session_id: sid)) do |opts|
645
+ allow(PWN::AI::Agent::Loop).to receive(:run).with(hash_including(request: 'Continue fixture', session_id: sid)) do |opts|
623
646
  expect(PWN::Sessions.to_llm_messages(session_id: opts[:session_id]).map { |row| row[:content] }).to eq(['Prior fixture request', 'Prior fixture answer'])
624
647
  opts[:on_tool].call('fixture callback', {}, 'fixture result')
625
648
  'Resumed fixture complete'
@@ -848,7 +871,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
848
871
  allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_wrap_original do |method, opts|
849
872
  method.call(opts.merge(thin: true))
850
873
  end
851
- expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
874
+ allow(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
852
875
  expect(opts[:http_body][:messages].find { |message| message[:role] == 'system' }[:content]).to start_with("Saved role\nsecond line")
853
876
  { choices: [{ message: { role: 'assistant', content: 'Role forwarded.', tool_calls: [] } }] }.to_json
854
877
  end
@@ -922,13 +945,15 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
922
945
 
923
946
  it 'runs the real Loop from the public launch, steering a blocked model without a second input reader' do
924
947
  loop_module = PWN::AI::Agent::Loop
925
- entered = Queue.new
948
+ entered = PWNTestPipeQueue.new
949
+ recorded = PWNTestPipeQueue.new
926
950
  calls = []
927
951
  Thread.current[:pwn_console_probe] = :copied
928
952
  allow(loop_module).to receive(:call_engine) do |opts|
929
953
  expect(Thread.current[:pwn_console_probe]).to eq(:copied)
930
954
  expect(Thread.current[:pwn_steering_input].reader).to be_nil
931
955
  calls << opts[:messages].map(&:dup)
956
+ recorded << calls.last
932
957
  if calls.length == 1
933
958
  entered << true
934
959
  Queue.new.pop
@@ -949,8 +974,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
949
974
  nil
950
975
  end
951
976
  end
977
+ calls << recorded.pop until recorded.empty?
952
978
  expect(calls.length).to eq(2)
953
- expect(calls.last.select { |row| row[:role] == 'user' }.map { |row| row[:content] }).to include('what color is a lemon?')
979
+ expect(calls.last.select { |row| row[:role].to_s == 'user' }.map { |row| row[:content] }).to include('what color is a lemon?')
954
980
  expect(@paint.join).to include('pwn-ai', 'ASSISTANT', 'MISSION', 'steering')
955
981
  ensure
956
982
  Thread.current[:pwn_console_probe] = nil
@@ -959,7 +985,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
959
985
  it 'opens a draft-preserving swarm workspace and sends only after explicit confirmation' do
960
986
  allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
961
987
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
962
- allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Workspace result')
988
+ allow(PWN::AI::Agent::Swarm).to receive(:ask).with(include(name: 'scout', request: 'mission draft')).and_return(reply: 'Workspace result')
963
989
  keys = "mission draft\u0007a".chars
964
990
  stage = 0
965
991
  launch(keys) do
@@ -979,11 +1005,11 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
979
1005
  "\u0004"
980
1006
  end
981
1007
  end
982
- expect(PWN::AI::Agent::Swarm).to have_received(:ask).with(include(name: 'scout', request: 'mission draft')).once
1008
+ expect(@paint.join).to include('Workspace result')
983
1009
  end
984
1010
 
985
1011
  it 'cancels a blocked model on Ctrl+C, returns idle, and does not kill another worker' do
986
- entered = Queue.new
1012
+ entered = PWNTestPipeQueue.new
987
1013
  foreign = Thread.new { sleep }
988
1014
  allow(PWN::AI::Agent::Loop).to receive(:call_engine) {
989
1015
  entered << true
@@ -995,7 +1021,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
995
1021
  if !cancelled && !entered.empty?
996
1022
  cancelled = true
997
1023
  "\u0003"
998
- elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
1024
+ elsif @paint.any? { |row| row.include?('Mission cancelled; all owned processes reaped') }
999
1025
  "\u0004"
1000
1026
  else
1001
1027
  Thread.pass
@@ -1010,13 +1036,13 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1010
1036
 
1011
1037
  [0, 3.1].each do |startup_delay|
1012
1038
  it "leaves a provider blocked in native IO without waiting for that read to return (startup #{startup_delay}s)" do
1013
- entered = Queue.new
1014
- returned = Queue.new
1039
+ entered = PWNTestPipeQueue.new
1040
+ returned = PWNTestPipeQueue.new
1015
1041
  provider = nil
1016
1042
  started = nil
1017
1043
  allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
1018
1044
  Thread.handle_interrupt(Exception => :never) do
1019
- entered << Thread.current
1045
+ entered << File.read('/proc/thread-self/stat').split.first
1020
1046
  sleep 8
1021
1047
  returned << true
1022
1048
  end
@@ -1025,12 +1051,12 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1025
1051
  launched = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1026
1052
  launch(keys, startup_delay: startup_delay, render_delay: startup_delay.positive? ? 0.02 : 0) do
1027
1053
  provider ||= entered.pop unless entered.empty?
1028
- if !started && provider&.status == 'sleep'
1054
+ if !started && provider && File.read("/proc/#{provider}/status").match?(/^State:\s+S/)
1029
1055
  # Elapsed is painted before call_engine: it is not a provider barrier.
1030
1056
  # Time from delivered Ctrl+C, excluding startup and per-key rendering.
1031
1057
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1032
1058
  "\u0003"
1033
- elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
1059
+ elsif @paint.any? { |row| row.include?('Mission cancelled; all owned processes reaped') }
1034
1060
  "\u0004"
1035
1061
  else
1036
1062
  Thread.pass
@@ -1041,24 +1067,17 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1041
1067
  expect(started - launched).to be >= startup_delay
1042
1068
  expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
1043
1069
  expect(returned).to be_empty
1070
+ expect(File.exist?("/proc/#{provider}")).to be(false)
1044
1071
  ensure
1045
- # Cancellation deliberately abandons masked IO. Release this fixture only
1046
- # after measuring return, then reap it before RSpec removes its mocks.
1047
- provider ||= entered.pop unless entered.empty?
1048
- begin
1049
- provider&.wakeup if provider&.alive?
1050
- rescue ThreadError
1051
- nil # The provider exited between alive? and wakeup.
1052
- end
1053
- provider&.join(2)
1054
- expect(provider).not_to be_alive if provider
1072
+ entered&.close
1073
+ returned&.close
1055
1074
  end
1056
1075
  end
1057
1076
 
1058
1077
  it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
1059
- entered = Queue.new
1078
+ entered = PWNTestPipeQueue.new
1060
1079
  calls = 0
1061
- observed = nil
1080
+ observed = PWNTestPipeQueue.new
1062
1081
  allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
1063
1082
  calls += 1
1064
1083
  if calls == 1
@@ -1067,10 +1086,11 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1067
1086
  { role: 'assistant', content: 'Yellow.', tool_calls: [] }
1068
1087
  end
1069
1088
  end
1070
- expect(PWN::AI::Agent::Dispatch).to receive(:call).once do
1089
+ allow(PWN::AI::Agent::Dispatch).to receive(:call) do
1071
1090
  entered << true
1072
- observed = $stdin.gets
1073
- JSON.generate(success: true, result: { stdout: observed, exitstatus: 0 })
1091
+ answer = $stdin.gets
1092
+ observed << answer
1093
+ JSON.generate(success: true, result: { stdout: answer, exitstatus: 0 })
1074
1094
  end
1075
1095
  expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_run_model)
1076
1096
  keys = "what color is a passion fruit?\n".chars
@@ -1087,14 +1107,15 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1087
1107
  nil
1088
1108
  end
1089
1109
  end
1090
- expect(observed).to eq("tool answer\n")
1110
+ expect(observed.pop).to eq("tool answer\n")
1111
+ expect(observed).to be_empty
1091
1112
  expect(@paint.join).to include('TOOL', 'RESULT', 'tool answer', 'Settings and new requests wait')
1092
1113
  expect(@paint.join).to include('tools 1')
1093
1114
  end
1094
1115
 
1095
1116
  it 'closes the tool prompt pipe on busy exit and joins rather than orphaning the active request' do
1096
- entered = Queue.new
1097
- finished = Queue.new
1117
+ entered = PWNTestPipeQueue.new
1118
+ finished = PWNTestPipeQueue.new
1098
1119
  allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(
1099
1120
  { role: 'assistant', tool_calls: [{ id: 'prompt', type: 'function', function: { name: 'shell', arguments: '{}' } }] }
1100
1121
  )
@@ -1106,7 +1127,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1106
1127
  end
1107
1128
  keys = "what color is a lemon?\n".chars
1108
1129
  launch(keys) { entered.empty? ? nil : "\u0004" }
1109
- expect(finished.pop).to be_nil
1130
+ expect(finished).to be_empty # Hard cancellation does not resume the tool to finish its writes.
1110
1131
  end
1111
1132
 
1112
1133
  it 'wraps menu selection without changing the draft or persistent history, then recalls after Escape' do
@@ -2512,14 +2533,14 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2512
2533
 
2513
2534
  it 'repaints settings and theme changed by a command worker without worker drawing or a wakeup key' do
2514
2535
  PWN::Env[:ai] = { active: :ollama, ollama: { model: 'settings-fixture' } }
2515
- owner = Thread.current
2536
+ owner = Process.pid
2516
2537
  painted = []
2517
2538
  allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive(:put).and_wrap_original do |method, *args|
2518
2539
  painted << args
2519
2540
  method.call(*args)
2520
2541
  end
2521
2542
  allow(PWN::Cron).to receive(:run) do
2522
- expect(Thread.current).not_to eq(owner)
2543
+ expect(Process.pid).not_to eq(owner)
2523
2544
  slot = PWN::Env[:ai][PWN::Env[:ai][:active].to_sym]
2524
2545
  slot[:system_role_content] = 'worker settings role'
2525
2546
  slot[:temp] = 0.42
@@ -2571,6 +2592,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2571
2592
  allow(PWN::Banner).to receive(:mini_names).and_return([:falling_blocks])
2572
2593
  allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
2573
2594
  expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_apply_model)
2595
+ allow(Time).to receive(:now).and_return(Time.now)
2596
+ # Compare model/settings/artwork, not the command's live status/timer.
2597
+ # Mission readiness and cancellation have separate regressions.
2598
+ allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive_messages(elapsed: '0.0s', state: 'fixture status')
2574
2599
  original = Marshal.dump(PWN::Env[:ai])
2575
2600
  keys = "/model grok grok-4.6\n".chars
2576
2601
  cancelled = false
@@ -2584,7 +2609,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2584
2609
  cancel_key
2585
2610
  elsif cancelled
2586
2611
  expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
2587
- expect(rendered_header).to eq(baseline)
2612
+ first_diff = baseline.chars.zip(rendered_header.chars).index { |left, right| left != right }
2613
+ expect(rendered_header).to eq(baseline), "header delta at #{first_diff}: #{baseline[first_diff.to_i, 180].inspect} -> #{rendered_header[first_diff.to_i, 180].inspect}"
2588
2614
  "\u0004"
2589
2615
  else
2590
2616
  Thread.pass
@@ -2618,7 +2644,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2618
2644
  PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
2619
2645
  allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
2620
2646
  allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'medium' }, { effort: 'high' }] }])
2621
- expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
2647
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
2622
2648
  { choices: [{ message: { role: 'assistant', content: 'Effort accepted.', tool_calls: [] } }] }
2623
2649
  end
2624
2650
  keys = "/model openai gpt-6-astra\n".chars
@@ -2643,13 +2669,22 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2643
2669
  PWN::Sessions.append(session_id: sid, role: 'user', content: 'Previous fixture question')
2644
2670
  PWN::Sessions.append(session_id: sid, role: 'assistant', content: 'Previous fixture answer')
2645
2671
  allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
2646
- expect(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
2672
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
2647
2673
  expect(PWN::Env.dig(:ai, :openai, :model)).to eq('console-fixture')
2648
2674
  expect(opts[:messages]).to include(hash_including(role: 'user', content: 'Previous fixture question'), hash_including(role: 'assistant', content: 'Previous fixture answer'))
2649
2675
  { choices: [{ message: { role: 'assistant', content: 'Selected route.', tool_calls: [] } }] }
2650
2676
  end
2651
- keys = "/model openai console-fixture\n/sessions resume resumed console\nwhat color is a lemon?\n".chars
2652
- launch(keys) { @paint.any? { |row| row.include?('Selected route.') } ? "\u0004" : nil }
2677
+ keys = "/model openai console-fixture\n/sessions resume resumed console\n".chars
2678
+ submitted = false
2679
+ launch(keys) do
2680
+ if !submitted && @pry.config.pwn_ai_session_id == sid
2681
+ submitted = true
2682
+ keys.concat("what color is a lemon?\n".chars)
2683
+ nil
2684
+ elsif @paint.any? { |row| row.include?('Selected route.') }
2685
+ "\u0004"
2686
+ end
2687
+ end
2653
2688
  expect(@pry.config.pwn_ai_session_id).to eq(sid)
2654
2689
  expect(PWN::Sessions.load(session_id: sid).map { |row| row[:content] }.join).to include('Selected route.')
2655
2690
  end
@@ -2713,9 +2748,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2713
2748
  end
2714
2749
 
2715
2750
  it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
2716
- entered = Queue.new
2751
+ entered = PWNTestPipeQueue.new
2717
2752
  expect(PWN::AI::Agent::Loop).not_to receive(:run)
2718
- expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
2753
+ allow(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
2719
2754
  entered << true
2720
2755
  puts "local command received: #{$stdin.gets}"
2721
2756
  true
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'timeout'
5
+
6
+ describe 'regex-ReAct mission containment' do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ let(:repl) { PWN::Plugins::REPL }
10
+ let(:request) { +'offline legacy request' }
11
+ let(:pry) { double('pry', config: Pry::Config.new, input: double('input', line_buffer: request)) }
12
+
13
+ before do
14
+ allow(Dir).to receive(:home).and_return(@tmp)
15
+ @work = File.join(@tmp, 'work')
16
+ FileUtils.mkdir_p(@work)
17
+ allow(repl).to receive(:ready_tty!)
18
+ allow(PWN::Memory).to receive(:to_context).and_return('')
19
+ PWN::Env[:ai] = { active: :ollama, agent: { native_tools: false }, ollama: { response_history: { id: 'before' } } }
20
+ pry.config.pwn_ai = true
21
+ pry.config.pwn_ai_agent = true
22
+ allow(Pry.config).to receive(:hooks).and_return(Pry::Hooks.new)
23
+ repl.add_hooks
24
+ end
25
+
26
+ def submit
27
+ Pry.config.hooks.get_hook(:after_read, :pwn_ai_hook).call(request, pry)
28
+ end
29
+
30
+ it 'fails closed before calling a provider when mission containment is unavailable' do
31
+ allow(PWN::Plugins::AISandbox::MissionProcess).to receive(:new).and_raise(PWN::Plugins::AISandbox::NotStarted, 'enable user/PID namespaces')
32
+ expect(PWN::AI::Ollama).not_to receive(:chat)
33
+ expect { submit }.to output(%r{enable user/PID namespaces.*no uncontained retry}).to_stderr
34
+ expect(request).to eq('nil')
35
+ expect(PWN::Env.dig(:ai, :ollama, :response_history)).to eq(id: 'before')
36
+ expect(repl).to have_received(:ready_tty!)
37
+ end
38
+
39
+ it 'does not enter regex fallback after a failed native mission' do
40
+ PWN::Env[:ai][:agent][:native_tools] = true
41
+ allow(repl).to receive(:pwn_ai_run_steerable).and_raise(IOError, 'mission unavailable')
42
+ expect(repl).not_to receive(:pwn_ai_run_legacy)
43
+ expect(PWN::AI::Ollama).not_to receive(:chat)
44
+ expect { submit }.to output(/native agent loop failed/).to_stderr
45
+ expect(request).to eq('nil')
46
+ end
47
+
48
+ it 'does not retry a failed legacy provider in the controller or replace committed history' do
49
+ marker = File.join(@tmp, 'provider-calls')
50
+ allow(PWN::AI::Ollama).to receive(:chat) do
51
+ File.open(marker, 'a') { |file| file.puts(File.read('/proc/self/status')[/^NSpid:.*$/]) }
52
+ raise IOError, 'offline provider failed'
53
+ end
54
+ expect { submit }.to output(/offline provider failed.*no uncontained retry/).to_stderr
55
+ expect(File.readlines(marker).length).to eq(1)
56
+ expect(File.read(marker).split.length).to be > 2
57
+ expect(PWN::Env.dig(:ai, :ollama, :response_history)).to eq(id: 'before')
58
+ expect(request).to eq('nil')
59
+ expect(repl).to have_received(:ready_tty!)
60
+ end
61
+
62
+ it 'runs the fake provider and Ruby block in a real mission and returns history to the controller' do
63
+ marker = File.join(PWN::Plugins::AISandbox.workspace, 'tool-owner')
64
+ diagnostic = File.join(@tmp, 'provider-observation')
65
+ calls = 0
66
+ allow(PWN::AI::Ollama).to receive(:chat) do |options|
67
+ calls += 1
68
+ File.write(diagnostic, options[:request].to_s) if calls > 1
69
+ text = calls == 1 ? "```ruby\nFile.write(#{marker.inspect}, File.read('/proc/self/status'))\n```" : 'offline complete'
70
+ { id: 'after', choices: [{ content: text }] }
71
+ end
72
+ submit
73
+ expect(File).to exist(marker), File.read(diagnostic)
74
+ expect(File.read(marker)[/^NSpid:.*$/].split.length).to be > 2
75
+ expect(calls).to eq(0)
76
+ expect(PWN::Env.dig(:ai, :ollama, :response_history, :id)).to eq('after')
77
+ expect(request).to eq('nil')
78
+ end
79
+
80
+ %w[ruby bash].each do |language|
81
+ it "denies protected source writes through the actual legacy #{language} hook" do
82
+ source = File.join(@tmp, 'synthetic-source')
83
+ FileUtils.mkdir_p(source)
84
+ target = File.join(source, 'forbidden.txt')
85
+ original = PWN::Plugins::AISandbox.protected_roots
86
+ allow(PWN::Plugins::AISandbox).to receive(:protected_roots).and_return([source, *original])
87
+ code = language == 'ruby' ? "File.write(#{target.inspect}, 'forbidden')" : "printf forbidden > #{target.shellescape}"
88
+ calls = 0
89
+ allow(PWN::AI::Ollama).to receive(:chat) do
90
+ calls += 1
91
+ { choices: [{ content: calls == 1 ? "```#{language}\n#{code}\n```" : 'completed' }] }
92
+ end
93
+ submit
94
+ expect(File).not_to exist(target)
95
+ expect(request).to eq('nil')
96
+ end
97
+ end
98
+
99
+ it 'cancels regex-ReAct with a real PTY CTRL+C and completes the next submitted mission' do
100
+ require 'pty'
101
+ script = File.join(@tmp, 'regex_pty.rb')
102
+ File.write(script, <<~'RUBY')
103
+ require 'pwn'
104
+ require 'pry'
105
+ require 'ostruct'
106
+ require 'fileutils'
107
+ $stdout.sync = true
108
+ work = ARGV.fetch(0)
109
+ runtime = File.join(Dir.home, '.pwn', 'workspaces', 'runtime')
110
+ FileUtils.mkdir_p(runtime)
111
+ PWN::Env[:ai] = { active: :ollama, ollama: {} }
112
+ PWN::Memory.define_singleton_method(:to_context) { |*| '' }
113
+ PWN::AI::Ollama.define_singleton_method(:chat) do |opts|
114
+ if opts[:request] == 'second'
115
+ { id: 'second', choices: [{ content: 'SECOND COMPLETE' }] }
116
+ else
117
+ payload = "Process.setsid; File.write(#{File.join(runtime, 'pty-ready').inspect}, File.read('/proc/self/status')); sleep 1; File.write(#{File.join(runtime, 'pty-late').inspect}, 'late'); sleep 30"
118
+ code = "Process.spawn(#{RbConfig.ruby.inspect}, '-e', #{payload.inspect}, out: File::NULL, err: File::NULL); sleep 30"
119
+ { choices: [{ content: "```ruby\n#{code}\n```" }] }
120
+ end
121
+ end
122
+ pi = OpenStruct.new(config: OpenStruct.new(pwn_ai_agent: true))
123
+ begin
124
+ PWN::Plugins::REPL.pwn_ai_run_legacy(request: +'first', original_request: 'first', pry: pi)
125
+ rescue Interrupt
126
+ puts 'CANCELLED READY'
127
+ end
128
+ next_request = $stdin.gets.to_s.strip
129
+ PWN::Plugins::REPL.pwn_ai_run_legacy(request: next_request.dup, original_request: next_request, pry: pi)
130
+ puts "HISTORY #{PWN::Env.dig(:ai, :ollama, :response_history, :id)}"
131
+ RUBY
132
+ output = +''
133
+ reader, writer, pid = PTY.spawn({ 'HOME' => @tmp }, RbConfig.ruby, '-I', File.expand_path('../../../../../lib', __dir__), script, @work)
134
+ drain = Thread.new do
135
+ loop { output << reader.readpartial(4096) }
136
+ rescue EOFError, Errno::EIO
137
+ nil
138
+ end
139
+ marker = File.join(@tmp, '.pwn', 'workspaces', 'runtime', 'pty-ready')
140
+ Timeout.timeout(10) { sleep 0.01 until File.exist?(marker) }
141
+ host_pid = File.read(marker)[/^NSpid:\s+(\d+)/, 1].to_i
142
+ writer.write("\x03")
143
+ Timeout.timeout(10) { sleep 0.01 until output.include?('CANCELLED READY') }
144
+ expect { Process.kill(0, host_pid) }.to raise_error(Errno::ESRCH)
145
+ writer.write("second\n")
146
+ Timeout.timeout(10) { Process.wait(pid) }
147
+ pid = nil
148
+ drain.join(5)
149
+ expect(output).to include('SECOND COMPLETE', 'HISTORY second')
150
+ sleep 1.1
151
+ expect(File).not_to exist(File.join(@tmp, '.pwn', 'workspaces', 'runtime', 'pty-late'))
152
+ ensure
153
+ if pid
154
+ begin
155
+ Process.kill('KILL', pid)
156
+ rescue StandardError
157
+ nil
158
+ end
159
+ begin
160
+ Process.wait(pid)
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ end
165
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
166
+ drain&.join(5)
167
+ end
168
+
169
+ %w[ruby bash].each do |language|
170
+ it "kills a detached #{language} descendant on interrupt before ready, then accepts another request" do
171
+ marker = File.join(PWN::Plugins::AISandbox.workspace, 'detached')
172
+ late = File.join(PWN::Plugins::AISandbox.workspace, 'late')
173
+ payload = "Process.setsid; File.write(#{marker.inspect}, File.read('/proc/self/status')); sleep 1; File.write(#{late.inspect}, 'escaped'); sleep 30"
174
+ ruby_code = "Process.spawn(#{RbConfig.ruby.inspect}, '-e', #{payload.inspect}, out: File::NULL, err: File::NULL); sleep 30"
175
+ code = language == 'ruby' ? ruby_code : [RbConfig.ruby, '-e', ruby_code].shelljoin
176
+ allow(PWN::AI::Ollama).to receive(:chat).and_return(choices: [{ content: "```#{language}\n#{code}\n```" }])
177
+ worker = Thread.new do
178
+ submit
179
+ rescue Interrupt
180
+ :cancelled
181
+ end
182
+ Timeout.timeout(10) { sleep 0.01 until File.exist?(marker) }
183
+ host_pid = File.read(marker)[/^NSpid:\s+(\d+)/, 1].to_i
184
+ worker.raise(Interrupt)
185
+ expect(Timeout.timeout(10) { worker.value }).to eq(:cancelled)
186
+ expect(repl).to have_received(:ready_tty!)
187
+ expect { Process.kill(0, host_pid) }.to raise_error(Errno::ESRCH)
188
+ sleep 1.1
189
+ expect(File).not_to exist(late)
190
+ allow(PWN::AI::Ollama).to receive(:chat).and_return(id: 'second', choices: [{ content: 'second complete' }])
191
+ request.replace('second request')
192
+ submit
193
+ expect(PWN::Env.dig(:ai, :ollama, :response_history, :id)).to eq('second')
194
+ expect(request).to eq('nil')
195
+ ensure
196
+ worker&.raise(Interrupt) if worker&.alive?
197
+ worker&.join(10)
198
+ end
199
+ end
200
+ end