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
@@ -5,17 +5,76 @@ require 'tmpdir'
5
5
  require 'fileutils'
6
6
 
7
7
  describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
8
+ around do |example|
9
+ Dir.mktmpdir('loop-controller-') do |control|
10
+ @runtime_control = control
11
+ example.run
12
+ end
13
+ end
14
+
15
+ before do
16
+ FileUtils.mkdir_p(File.join(@runtime_control, 'sessions'))
17
+ allow(PWN::Sessions).to receive(:sessions_dir).and_return(File.join(@runtime_control, 'sessions'))
18
+ end
19
+
8
20
  describe 'runtime execution boundaries' do
9
21
  include_context 'pwn tmp sandbox'
10
22
 
11
23
  before do
12
24
  allow(Dir).to receive(:home).and_return(@tmp)
25
+ @work = File.join(@tmp, 'work')
26
+ FileUtils.mkdir_p(@work)
13
27
  stub_const('PWN::AI::Agent::OpenGoal::GOAL_FILE', File.join(@tmp, 'open_goal.json'))
14
28
  allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
15
29
  allow(described_class).to receive(:should_auto_introspect?).and_return(false)
16
30
  @agent_cfg[:plan_first] = false
17
31
  end
18
32
 
33
+ it 'finishes the offline PDF flow after cumulative combined and separate host observations' do
34
+ FileUtils.mkdir_p(File.join(@tmp, 'output'))
35
+ pdf = File.join(@tmp, 'output', 'container_pentest-PWN.pdf')
36
+ note = File.join(@tmp, 'output', 'notes.txt')
37
+ request = "Generate a report, i.e. a PDF; save #{pdf} and #{note}"
38
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'pdf-replay', engine: :openai, artifact_root: @runtime_control, trace: false)
39
+ code = "PWN::Reports::PDF.generate(path: #{pdf.inspect}, title: 'Offline pentest fixture', findings: []); File.read(#{pdf.inspect}, 8)"
40
+ turns = [
41
+ { role: 'assistant', tool_calls: [{ id: 'pdf-write', type: 'function', function: { name: 'pwn_eval', arguments: { code: code }.to_json } }] },
42
+ { role: 'assistant', tool_calls: [{ id: 'note-write', type: 'function', function: { name: 'shell', arguments: { command: "printf verified > #{note}", timeout: 10 }.to_json } }] },
43
+ { role: 'assistant', tool_calls: [{ id: 'readback', type: 'function', function: { name: 'shell', arguments: { command: "stat #{pdf}; sha256sum #{pdf}; cat #{note}", timeout: 10 }.to_json } }] },
44
+ { role: 'assistant', content: 'The PDF and notes are written and verified.' }
45
+ ]
46
+ hops = 0
47
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do
48
+ hops += 1
49
+ raise 'unexpected completion bounce' if turns.empty?
50
+
51
+ { choices: [{ message: turns.shift }] }
52
+ end
53
+ tool_results = []
54
+ result = described_class.run(request: request, session_id: 'pdf-replay', runtime: runtime, system_role_content: 'offline fixture', on_tool: ->(*row) { tool_results << row })
55
+ expect(result).to eq('The PDF and notes are written and verified.'), tool_results.inspect
56
+ expect(hops).to eq(4)
57
+ expect(runtime.contract[:required_artifacts]).to contain_exactly(pdf, note)
58
+ expect(runtime.outcome[:status]).to eq('complete')
59
+ expect(runtime.observations.keys).to contain_exactly(pdf, note)
60
+ expect(File.binread(pdf)).to start_with('%PDF-1.4')
61
+ expect(File.binread(pdf)).to include('%%EOF')
62
+ expect(runtime.observations[pdf][:sha256]).to eq(Digest::SHA256.file(pdf).hexdigest)
63
+ end
64
+
65
+ it 'returns a precise blocked outcome instead of bouncing uncertain effects forever' do
66
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Perform the fixture mutation', session_id: 'uncertain-replay', engine: :openai, artifact_root: @runtime_control, trace: false)
67
+ expect do
68
+ runtime.execute_tool(name: 'fixture_write', args: { target: 'exact-target' }, effect: :write, action_id: 'uncertain-call') { raise Timeout::Error }
69
+ end.to raise_error(Timeout::Error)
70
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools).and_return(choices: [{ message: { role: 'assistant', content: 'Done.' } }])
71
+ result = described_class.run(request: runtime.request, session_id: runtime.session_id, runtime: runtime, system_role_content: 'offline fixture')
72
+ expect(result).to include('blocked:', 'uncertain-call', 'operator-approved reconciliation')
73
+ expect(runtime.outcome[:status]).to eq('blocked')
74
+ expect(runtime.unmet).to eq(['uncertain_side_effect'])
75
+ expect(PWN::AI::OpenAI).to have_received(:chat_with_tools).once
76
+ end
77
+
19
78
  it 'verifies an exact HTTP target at the actual Loop finalization boundary' do
20
79
  require 'socket'
21
80
  server = TCPServer.new('127.0.0.1', 0)
@@ -30,7 +89,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
30
89
  client.close
31
90
  end
32
91
  sid = PWN::Sessions.create(title: 'http verifier fixture')[:id]
33
- runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, engine: :openai, artifact_root: @tmp, trace: false)
92
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, engine: :openai, artifact_root: @runtime_control, trace: false)
34
93
  allow(PWN::AI::OpenAI).to receive(:chat_with_tools).and_return(choices: [{ message: { role: 'assistant', content: 'Checked.' } }])
35
94
  contract = { root: @tmp, requirements: [request], allowed_urls: [url], checks: [{ requirement: request, kind: :http, url: url, expected: 'ok' }] }
36
95
  result = described_class.run(request: request, session_id: sid, runtime: runtime, verification_contract: contract, enabled_toolsets: [], system_role_content: 'fixture')
@@ -113,16 +172,17 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
113
172
  end
114
173
 
115
174
  it 'cancels a real mutation in Loop and denies its replay after checkpoint restore' do
116
- counter = File.join(@tmp, 'cancel-counter')
175
+ counter = File.join(@work, 'cancel-counter.txt')
117
176
  cancelled = false
118
- options = { request: 'Run the fixture mutation', session_id: 'tool-cancel', request_id: 'same', engine: :openai,
119
- artifact_root: @tmp, trace: false, cancel: -> { cancelled } }
177
+ options = { request: "Run the fixture mutation and write #{counter}", session_id: 'tool-cancel', request_id: 'same', engine: :openai,
178
+ artifact_root: @runtime_control, trace: false, cancel: -> { cancelled } }
120
179
  runtime = PWN::AI::Agent::RequestRuntime.new(options)
121
180
  args = { command: "printf 'once\\n' >> #{counter}; sleep 30", timeout: 180 }
122
181
  reply = { role: 'assistant', tool_calls: [{ id: 'mutation', type: 'function', function: { name: 'shell', arguments: args.to_json } }] }
123
182
  allow(PWN::AI::OpenAI).to receive(:chat_with_tools).and_return(choices: [{ message: reply }])
124
183
  canceller = Thread.new do
125
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
184
+ # Wait for the actual mutation, including cold kernel-policy inventory.
185
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
126
186
  sleep 0.01 until File.exist?(counter) || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
127
187
  cancelled = true
128
188
  end
@@ -163,18 +223,30 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
163
223
  end
164
224
 
165
225
  it 'overlaps independent reads while preserving real write barriers and start/end order' do
166
- left = File.join(@tmp, 'left.txt')
167
- right = File.join(@tmp, 'right.txt')
168
- output = File.join(@tmp, 'out.txt')
226
+ left = File.join(@work, 'left.txt')
227
+ right = File.join(@work, 'right.txt')
228
+ output = File.join(@work, 'out.txt')
169
229
  File.write(left, 'left')
170
230
  File.write(right, 'right')
171
- timings = []
172
- lock = Mutex.new
231
+ timing_path = File.join(@work, 'timings.jsonl')
173
232
  allow(PWN::Plugins::ArtifactRegistry).to receive(:read_page).and_wrap_original do |original, options|
174
233
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
- sleep 0.05
234
+ rendezvous = "#{timing_path}.#{File.exist?(output) ? 'after' : 'before'}"
235
+ File.open(rendezvous, 'a') do |file|
236
+ file.flock(File::LOCK_EX)
237
+ file.puts('started')
238
+ end
239
+ deadline = start + 10
240
+ until File.readlines(rendezvous).length == 2
241
+ raise 'parallel reads did not rendezvous' if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
242
+
243
+ sleep 0.005
244
+ end
176
245
  value = original.call(options)
177
- lock.synchronize { timings << [start, Process.clock_gettime(Process::CLOCK_MONOTONIC)] }
246
+ File.open(timing_path, 'a') do |file|
247
+ file.flock(File::LOCK_EX)
248
+ file.puts(JSON.generate([start, Process.clock_gettime(Process::CLOCK_MONOTONIC)]))
249
+ end
178
250
  value
179
251
  end
180
252
  calls = [left, right].each_with_index.map { |path, index| { id: "read#{index}", type: 'function', function: { name: 'artifact_read', arguments: { path: path }.to_json } } }
@@ -183,8 +255,9 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
183
255
  calls.concat([left, right].each_with_index.map { |path, index| { id: "after#{index}", type: 'function', function: { name: 'artifact_read', arguments: { path: path }.to_json } } })
184
256
  replies = [{ role: 'assistant', tool_calls: calls }, { role: 'assistant', content: 'Verified.' }]
185
257
  allow(PWN::AI::OpenAI).to receive(:chat_with_tools) { { choices: [{ message: replies.shift || raise('unexpected provider call') }] } }
186
- runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{output}", session_id: 'parallel', engine: :openai, trace: false, artifact_root: @tmp)
258
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{output}", session_id: 'parallel', engine: :openai, trace: false, artifact_root: @runtime_control)
187
259
  expect(described_class.run(request: runtime.request, runtime: runtime, session_id: 'parallel', system_role_content: 'fixture')).to eq('Verified.')
260
+ timings = File.readlines(timing_path).map { |line| JSON.parse(line) }
188
261
  expect(timings.length).to eq(4)
189
262
  expect(timings[0][0]).to be < timings[1][1]
190
263
  expect(timings[1][0]).to be < timings[0][1]
@@ -205,6 +278,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
205
278
 
206
279
  before do
207
280
  allow(Dir).to receive(:home).and_return(@tmp)
281
+ @work = File.join(@tmp, 'work')
282
+ FileUtils.mkdir_p(@work)
208
283
  stub_const('PWN::AI::Agent::OpenGoal::GOAL_FILE', File.join(@tmp, 'open_goal.json'))
209
284
  allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
210
285
  allow(described_class).to receive(:should_auto_introspect?).and_return(false)
@@ -212,7 +287,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
212
287
  end
213
288
 
214
289
  it 'accepts Verification complete after an actual write without wording-dependent retries' do
215
- path = File.join(@tmp, 'answer.txt')
290
+ path = File.join(@work, 'answer.txt')
216
291
  replies = [
217
292
  { role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf verified > #{path}" }.to_json } }] },
218
293
  { role: 'assistant', content: 'Verification complete.' }
@@ -240,7 +315,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
240
315
  end
241
316
 
242
317
  it 'automatically restores the session checkpoint identity for continue' do
243
- runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{@tmp}/resume.txt", session_id: 'resume', request_id: 'original', artifact_root: @tmp, trace: false)
318
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{@tmp}/resume.txt", session_id: 'resume', request_id: 'original', artifact_root: @runtime_control, trace: false)
244
319
  Thread.current[:pwn_request_runtime] = runtime
245
320
  PWN::AI::Agent::OpenGoal.begin!(request: runtime.request, session_id: 'resume')
246
321
  runtime.finish!(status: 'incomplete')
@@ -259,7 +334,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
259
334
 
260
335
  it 'drives the actual runtime/provider adapter boundary and harmless Dispatch write' do
261
336
  PWN::Env[:ai][:active] = :openai
262
- path = File.join(@tmp, 'provider.txt')
337
+ path = File.join(@work, 'provider.txt')
263
338
  replies = [
264
339
  { role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf provider > #{path}" }.to_json } }] },
265
340
  { role: 'assistant', content: 'Verification complete.' }
@@ -274,8 +349,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
274
349
  end
275
350
 
276
351
  it 'retains completion evidence after all tool messages are evicted' do
277
- path = File.join(@tmp, 'compacted.txt')
278
- runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{path}", session_id: 'compact', artifact_root: @tmp, trace: false)
352
+ path = File.join(@work, 'compacted.txt')
353
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: "Write #{path}", session_id: 'compact', artifact_root: @runtime_control, trace: false)
279
354
  replies = [
280
355
  { role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf compacted > #{path}" }.to_json } }] },
281
356
  { role: 'assistant', content: 'Verification complete.' }
@@ -354,7 +429,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
354
429
 
355
430
  it 'terminates checker errors with a durable verification_error outcome, not a success' do
356
431
  request = "Write #{File.join(@tmp, 'error.txt')}"
357
- runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'error', artifact_root: @tmp, trace: false)
432
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'error', artifact_root: @runtime_control, trace: false)
358
433
  allow(PWN::AI::Agent::TurnFinalizer).to receive(:arbitrate).and_raise(IOError, 'checker unavailable')
359
434
  allow(described_class).to receive(:call_engine).and_return(role: 'assistant', content: 'Done.')
360
435
  expect(described_class).to receive(:call_engine).once
@@ -363,10 +438,10 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
363
438
  end
364
439
 
365
440
  it 'rejects a failed explicit content check before publishing a successful final' do
366
- path = File.join(@tmp, 'checked.txt')
441
+ path = File.join(@work, 'checked.txt')
367
442
  request = "Write #{path} containing correct"
368
443
  sid = PWN::Sessions.create(title: 'verification fixture')[:id]
369
- runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @tmp, trace: false)
444
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @runtime_control, trace: false)
370
445
  replies = [
371
446
  { role: 'assistant', tool_calls: [{ id: 'write', type: 'function', function: { name: 'shell', arguments: { command: "printf wrong > #{path}" }.to_json } }] },
372
447
  { role: 'assistant', content: 'Done.' }
@@ -379,13 +454,13 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
379
454
  end
380
455
 
381
456
  it 'keeps completed evidence and original destinations through additive steering at the real entry' do
382
- first = File.join(@tmp, 'first.txt')
383
- second = File.join(@tmp, 'second.txt')
457
+ first = File.join(@work, 'first.txt')
458
+ second = File.join(@work, 'second.txt')
384
459
  input, writer = IO.pipe
385
460
  control = described_class::Steering.new(input: input, output: StringIO.new)
386
461
  request = "Write #{first}"
387
462
  sid = PWN::Sessions.create(title: 'amendment verification')[:id]
388
- runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @tmp, trace: false)
463
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: sid, artifact_root: @runtime_control, trace: false)
389
464
  count = 0
390
465
  allow(described_class).to receive(:call_engine) do
391
466
  count += 1
@@ -418,7 +493,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
418
493
  it 'persists cancellation and restores the runtime binding without a provider restart' do
419
494
  input, writer = IO.pipe
420
495
  control = described_class::Steering.new(input: input, output: StringIO.new)
421
- runtime = PWN::AI::Agent::RequestRuntime.new(request: 'What color is a lemon?', session_id: 'cancel', artifact_root: @tmp, trace: false)
496
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'What color is a lemon?', session_id: 'cancel', artifact_root: @runtime_control, trace: false)
422
497
  control.submit('cancel')
423
498
  expect(described_class).not_to receive(:call_engine)
424
499
  result = described_class.run(request: runtime.request, runtime: runtime, session_id: 'cancel', steering: control,
@@ -451,7 +526,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
451
526
 
452
527
  it 'does not reuse a previous terminal success when the resumed provider returns nothing' do
453
528
  request = 'What color is a lemon?'
454
- runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'resume', artifact_root: @tmp, trace: false)
529
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: request, session_id: 'resume', artifact_root: @runtime_control, trace: false)
455
530
  runtime.finish!(status: 'complete')
456
531
  allow(described_class).to receive(:call_engine).and_return(nil)
457
532
  described_class.run(request: request, runtime: runtime, session_id: 'resume', enabled_toolsets: [], system_role_content: 'fixture')
@@ -516,7 +591,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
516
591
  described_class.send(:wait_trace_step!, label: 'engine')
517
592
  'done'
518
593
  end
519
- expect(PWN::Plugins::REPL.pwn_ai_run_steerable(input: input, output: StringIO.new, request: 'test')).to eq('done')
594
+ expect(PWN::Plugins::REPL.pwn_ai_run_steerable_child(input: input, output: StringIO.new, request: 'test')).to eq('done')
520
595
  expect(Thread.current[:pwn_steering_input]).to be_nil
521
596
  ensure
522
597
  input&.close
@@ -659,7 +734,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
659
734
  queued.pop
660
735
  writer.puts('tool answer')
661
736
  end
662
- result = PWN::Plugins::REPL.pwn_ai_run_steerable(
737
+ result = PWN::Plugins::REPL.pwn_ai_run_steerable_child(
663
738
  input: input, output: output, request: old_request,
664
739
  session_id: sid, system_role_content: 'test system', enabled_toolsets: [], on_tool: on_tool
665
740
  )
@@ -701,7 +776,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
701
776
  entered.pop
702
777
  writer.puts('/steer what color is a lemon?')
703
778
  end
704
- result = PWN::Plugins::REPL.pwn_ai_run_steerable(
779
+ result = PWN::Plugins::REPL.pwn_ai_run_steerable_child(
705
780
  input: input, output: output, request: 'what color is a passion fruit?',
706
781
  session_id: sid, system_role_content: 'test system', enabled_toolsets: []
707
782
  )
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe 'PWN::AI::Agent::MissionNamespace compiler-free process ownership' do
7
+ around do |example|
8
+ previous = PWN::AI::Agent::MissionNamespace.force_exec
9
+ PWN::AI::Agent::MissionNamespace.force_exec = true
10
+ example.run
11
+ ensure
12
+ PWN::AI::Agent::MissionNamespace.force_exec = previous
13
+ end
14
+
15
+ it 'loads through root and direct requires without suppressing sibling autoloads' do
16
+ %w[pwn pwn/ai/agent/mission_namespace].each do |entry|
17
+ code = <<~RUBY
18
+ require ARGV.fetch(0)
19
+ owner = PWN::AI::Agent::MissionNamespace
20
+ abort 'wrong owner' unless owner.name == 'PWN::AI::Agent::MissionNamespace'
21
+ abort 'root alias' if PWN.const_defined?(:MissionNamespace, false)
22
+ abort 'missing context' unless PWN::AI::Context.respond_to?(:help)
23
+ abort 'missing runtime' unless PWN::AI::Agent::RequestRuntime.respond_to?(:help)
24
+ puts owner.name
25
+ RUBY
26
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../../../../../lib', __dir__), '-e', code, entry)
27
+ expect(status.success?).to be(true), stderr
28
+ expect(stdout.strip).to eq('PWN::AI::Agent::MissionNamespace')
29
+ end
30
+ end
31
+
32
+ it 'preserves permitted YAML dates, timestamps, symbols and binary strings in snapshots' do
33
+ value = { date: Date.new(2026, 10, 29), time: Time.at(123, 456, :nanosecond).getlocal('+09:00'), bytes: "\xFF".b, route: :openai }
34
+ restored = PWN::AI::Agent::MissionNamespace.decode(PWN::AI::Agent::MissionNamespace.encode(value))
35
+ expect(restored).to eq(value)
36
+ expect(restored[:time].utc_offset).to eq(value[:time].utc_offset)
37
+ end
38
+
39
+ it 'preserves the exit status of a failed mission' do
40
+ mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: [RbConfig.ruby, '-e', 'exit! 73'] })
41
+ mission.wait
42
+ expect(mission.status.exitstatus).to eq(73)
43
+ ensure
44
+ mission&.close
45
+ end
46
+
47
+ it 'terminates owned descendants when the controller dies' do
48
+ Dir.mktmpdir('mission-owner-') do |root|
49
+ ready = File.join(root, 'ready')
50
+ late = File.join(root, 'late')
51
+ owner = fork do
52
+ code = "Process.setsid; File.write(ARGV[0], 'ready'); sleep 1; File.write(ARGV[1], 'late')"
53
+ mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: [RbConfig.ruby, '-e', code, ready, late] })
54
+ sleep 30
55
+ ensure
56
+ mission&.close
57
+ exit!
58
+ end
59
+ Timeout.timeout(10) { sleep 0.01 until File.exist?(ready) }
60
+ Process.kill('KILL', owner)
61
+ Process.wait(owner)
62
+ owner = nil
63
+ sleep 1.2
64
+ expect(File.exist?(late)).to be(false)
65
+ ensure
66
+ if owner
67
+ Process.kill('KILL', owner)
68
+ Process.wait(owner)
69
+ end
70
+ end
71
+ end
72
+
73
+ it 'rejects cyclic snapshots before attempting a launch' do
74
+ value = {}
75
+ value[:self] = value
76
+ expect { PWN::AI::Agent::MissionNamespace.encode(value) }.to raise_error(ArgumentError, /snapshot.*deep/)
77
+ end
78
+
79
+ it 'allows cancellation while another thread waits for completion' do
80
+ mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: ['/bin/sleep', '30'] })
81
+ waiter = Thread.new { mission.wait }
82
+ sleep 0.05
83
+ Timeout.timeout(5) do
84
+ mission.close
85
+ waiter.join
86
+ end
87
+ expect(waiter.value).to eq(:reaped)
88
+ ensure
89
+ mission&.close
90
+ waiter&.kill&.join
91
+ end
92
+
93
+ it 'enforces the filesystem boundary through a fresh descriptor worker' do
94
+ Dir.mktmpdir('mission-source-') do |root|
95
+ source = File.join(root, 'source')
96
+ work = File.join(root, 'work')
97
+ FileUtils.mkdir_p([source, work])
98
+ File.write(File.join(source, 'keep'), 'original')
99
+ result = PWN::Plugins::AISandbox.protect_call(roots: [source], workspace: work, write_roots: [work],
100
+ handler: { name: 'shell', args: { command: "printf allowed > result; printf bad > #{source}/keep", timeout: 5 } })
101
+ expect(result[:stderr]).to include('Permission denied')
102
+ expect(File.read(File.join(source, 'keep'))).to eq('original')
103
+ expect(File.read(File.join(work, 'result'))).to eq('allowed')
104
+ end
105
+ end
106
+
107
+ it 'executes a console slash descriptor after a fresh exec' do
108
+ reader, writer = IO.pipe
109
+ input, sender = IO.pipe
110
+ mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :slash, request: '/help', generation: 'test', config: {}, baseline_ai: JSON.generate(PWN::Env[:ai]) }, fds: { 4 => writer, 5 => input })
111
+ writer.close
112
+ rows = Timeout.timeout(10) { reader.readlines.map { |line| JSON.parse(line) } }
113
+ mission.wait
114
+ expect(rows.map { |row| row[1] }).to include('command_state')
115
+ expect(rows.none? { |row| row[1] == 'warning' }).to be(true)
116
+ ensure
117
+ mission&.close
118
+ [reader, writer, input, sender].compact.each { |io| io.close unless io.closed? }
119
+ end
120
+
121
+ it 'executes a descriptor and reaps detached descendants without an extension' do
122
+ Dir.mktmpdir('mission-ruby-') do |root|
123
+ ready = File.join(root, 'ready')
124
+ late = File.join(root, 'late')
125
+ code = "fork { Process.setsid; File.write(ARGV[0], 'ready'); sleep 1; File.write(ARGV[1], 'late') }; sleep 30"
126
+ mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: [RbConfig.ruby, '-e', code, ready, late] })
127
+ Timeout.timeout(10) { sleep 0.01 until File.exist?(ready) }
128
+ mission.close
129
+ sleep 1.1
130
+ expect(File.exist?(late)).to be(false)
131
+ next_mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :command, argv: ['/bin/true'] })
132
+ expect(next_mission.wait).to eq(:reaped)
133
+ ensure
134
+ mission&.close
135
+ next_mission&.close
136
+ end
137
+ end
138
+ end
@@ -60,14 +60,9 @@ describe 'PWN::AI::Agent::PolicyEvaluation' do
60
60
  expect(second[:isolation][:environment_keys]).to eq(%w[HOME LANG TMPDIR])
61
61
  end
62
62
 
63
- it 'exports learned snapshots and repeated independent reports only on explicit benchmark opt-in' do
64
- output, error, status = Open3.capture3(
65
- { 'HOME' => @root, 'LANG' => 'C.UTF-8' },
66
- RbConfig.ruby, File.expand_path('../../../../../scripts/benchmark_policy.rb', __dir__),
67
- '--heldout', '--snapshot-dir', File.join(@root, 'snapshots'), unsetenv_others: true
68
- )
69
- expect(status.success?).to be(true), error
70
- report = JSON.parse(output, symbolize_names: true)
63
+ it 'exports learned snapshots through PolicyEvaluation without a standalone script' do
64
+ evaluator = PWN::AI::Agent::PolicyEvaluation
65
+ report = evaluator.export_training(dir: File.join(@root, 'snapshots'))
71
66
  expect(report[:heldout].map { |row| row[:seed] }).to eq([0, 1])
72
67
  expect(File.file?(File.join(@root, 'snapshots', 'on.json'))).to be(true)
73
68
  report[:heldout].each do |row|
@@ -75,6 +70,7 @@ describe 'PWN::AI::Agent::PolicyEvaluation' do
75
70
  expect(row[:arms][:candidate][:evaluation][:completed]).to be > row[:arms][:baseline][:evaluation][:completed]
76
71
  end
77
72
  expect(report).not_to have_key(:promoted)
73
+ expect(File).not_to exist(File.expand_path('../../../../../scripts/benchmark_policy.rb', __dir__))
78
74
  end
79
75
 
80
76
  it 'requires opt-in and promotes a replay-verified candidate with a byte-exact rollback snapshot' do
@@ -83,13 +79,8 @@ describe 'PWN::AI::Agent::PolicyEvaluation' do
83
79
  live = File.join(@root, 'live.json')
84
80
  File.write(live, File.read(@baseline))
85
81
  expect(evaluator.promote(live_path: live)).to include(promoted: false, reason: 'disabled')
86
- output, error, status = Open3.capture3(
87
- { 'HOME' => @root, 'LANG' => 'C.UTF-8' }, RbConfig.ruby,
88
- File.expand_path('../../../../../scripts/benchmark_policy.rb', __dir__),
89
- '--heldout', '--snapshot-dir', File.join(@root, 'snapshots'), unsetenv_others: true
90
- )
91
- expect(status.success?).to be(true), error
92
- reports = JSON.parse(output, symbolize_names: true)[:heldout]
82
+ report = evaluator.export_training(dir: File.join(@root, 'snapshots'))
83
+ reports = report[:heldout]
93
84
  baseline = File.join(@root, 'snapshots', 'off.json')
94
85
  candidate = File.join(@root, 'snapshots', 'on.json')
95
86
  File.write(live, File.read(baseline))
@@ -214,13 +205,9 @@ describe 'PWN::AI::Agent::PolicyEvaluation' do
214
205
  end.to raise_error(ArgumentError, /regular file/)
215
206
  end
216
207
 
217
- it 'exercises the snapshot worker as part of the standalone script self-check' do
218
- output, error, status = Open3.capture3(
219
- { 'HOME' => @root, 'LANG' => 'C.UTF-8' }, RbConfig.ruby,
220
- File.expand_path('../../../../../scripts/benchmark_policy.rb', __dir__),
221
- '--self-check', unsetenv_others: true
222
- )
223
- expect(status.success?).to be(true), error
224
- expect(output).to include('Snapshot self-checks passed', 'Self-checks passed')
208
+ it 'does not keep a standalone benchmark script' do
209
+ expect(File).not_to exist(File.expand_path('../../../../../scripts/benchmark_policy.rb', __dir__))
210
+ source = File.read(PWN::AI::Agent::PolicyEvaluation.method(:evaluate).source_location.first)
211
+ expect(source).not_to include('scripts/benchmark_policy.rb')
225
212
  end
226
213
  end
@@ -6,6 +6,54 @@ require 'pwn/ai/agent/request_runtime'
6
6
  describe PWN::AI::Agent::RequestRuntime do
7
7
  include_context 'pwn tmp sandbox'
8
8
 
9
+ it 'runs arbitrary runtime dispatch handlers behind the mandatory source boundary' do
10
+ registry = PWN::AI::Agent::Registry
11
+ entries = registry.instance_variable_get(:@entries).dup
12
+ discovered = registry.instance_variable_get(:@discovered)
13
+ protected = File.join(@tmp, 'checkout')
14
+ FileUtils.mkdir_p(protected)
15
+ target = File.join(protected, 'keep')
16
+ File.write(target, 'original')
17
+ allow(PWN::Plugins::AISandbox).to receive(:protected_roots).and_return([protected])
18
+ runtime = described_class.new(request: 'Write a report', session_id: 'protected', artifact_root: @tmp, trace: false)
19
+ Thread.current[:pwn_request_runtime] = runtime
20
+ PWN::AI::Agent::Registry.register(name: 'boundary_fixture', toolset: 'test', schema: { name: 'boundary_fixture', parameters: { type: 'object' } }, handler: ->(_args) { File.write(target, 'bad') })
21
+ response = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { id: 'protected', function: { name: 'boundary_fixture', arguments: '{}' } }))
22
+ expect(File.read(target)).to eq('original')
23
+ expect(response['success']).to be false
24
+ ensure
25
+ registry.instance_variable_set(:@entries, entries)
26
+ registry.instance_variable_set(:@discovered, discovered)
27
+ Thread.current[:pwn_request_runtime] = nil
28
+ end
29
+
30
+ it 'accumulates host artifact receipts from successful eval writes without transcript claims' do
31
+ first = File.join(@tmp, 'first.pdf')
32
+ second = File.join(@tmp, 'second.txt')
33
+ runtime = described_class.new(request: "Write #{first} and #{second}", session_id: 'cumulative', artifact_root: @tmp, trace: false)
34
+ Thread.current[:pwn_request_runtime] = runtime
35
+ Thread.current[:pwn_artifact_contract] = runtime.contract
36
+ runtime.execute_tool(name: 'fixture', args: { file: first }, effect: :eval, action_id: 'one') do
37
+ File.write(first, '%PDF-1.4 fixture')
38
+ { value: 'written' }
39
+ end
40
+ runtime.execute_tool(name: 'fixture', args: { file: second }, effect: :eval, action_id: 'two') do
41
+ File.write(second, 'verified')
42
+ { value: 'written' }
43
+ end
44
+ runtime.execute_tool(name: 'fixture', args: { file: second }, effect: :read, action_id: 'three') do
45
+ File.read(second)
46
+ { value: 'read' }
47
+ end
48
+ expect(runtime.observations.keys).to contain_exactly(first, second)
49
+ expect(PWN::AI::Agent::TurnFinalizer.arbitrate(request: runtime.request)[:complete]).to be true
50
+ File.write(first, 'stale')
51
+ expect(PWN::AI::Agent::TurnFinalizer.arbitrate(request: runtime.request)[:complete]).to be false
52
+ ensure
53
+ Thread.current[:pwn_request_runtime] = nil
54
+ Thread.current[:pwn_artifact_contract] = nil
55
+ end
56
+
9
57
  it 'accepts a binary-tagged request without raising JSON::GeneratorError' do
10
58
  request = "status \u2022 ok".b
11
59
  expect(request.encoding).to eq(Encoding::ASCII_8BIT)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/ai/agent/request_runtime'
5
+
6
+ describe 'finished shell searches do not block the answer' do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ it 'keeps a non-zero search exit as a failed observation' do
10
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'how does pwn-ai use tools configured with openwebUi?', session_id: 'search-exit', artifact_root: @tmp, trace: false)
11
+ result = runtime.execute_tool(name: 'shell', args: { command: 'rg openwebui' }, effect: :exclusive, action_id: 'search') do
12
+ { stdout: 'open_web_ui.rb', stderr: '', exit: 1 }
13
+ end
14
+ expect(result[:exit]).to eq(1)
15
+ expect(runtime.events.last[:state]).to eq('failed')
16
+ expect(runtime.blocker).to be_nil
17
+ expect(runtime.unmet).to be_empty
18
+ again = runtime.execute_tool(name: 'shell', args: { command: 'rg openwebui' }, effect: :exclusive, action_id: 'search-again') do
19
+ { stdout: 'open_web_ui.rb', stderr: '', exit: 1 }
20
+ end
21
+ expect(again[:exit]).to eq(1)
22
+ end
23
+
24
+ it 'does not demand reconciliation for an interrupted non-write shell command' do
25
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'how does pwn-ai use tools configured with openwebUi?', session_id: 'search-interrupt', artifact_root: @tmp, trace: false)
26
+ call = { name: 'shell', args: { command: 'rg openwebui' }, effect: :exclusive, action_id: 'search' }
27
+ expect { runtime.execute_tool(call) { raise Timeout::Error } }.to raise_error(Timeout::Error)
28
+ expect(runtime.blocker).to be_nil
29
+ expect(runtime.unmet).to be_empty
30
+ denied = runtime.execute_tool(call.merge(action_id: 'replay')) { raise 'replayed' }
31
+ expect(denied[:error]).to eq('uncertain_side_effect')
32
+ end
33
+
34
+ it 'does not block the answer when a classified write returns permission denied' do
35
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'how does pwn-ai use tools configured with openwebUi?', session_id: 'write-denied', artifact_root: @tmp, trace: false)
36
+ result = runtime.execute_tool(name: 'shell', args: { command: "python -c 'open(\"/tmp/server_tools.rb\",\"w\")'" }, effect: :write, action_id: 'denied-write') do
37
+ { stdout: '', stderr: 'PermissionError: Permission denied: /tmp/server_tools.rb', exit: 1 }
38
+ end
39
+ expect(result[:exit]).to eq(1)
40
+ expect(runtime.events.last[:state]).to eq('failed')
41
+ expect(runtime.blocker).to be_nil
42
+ expect(runtime.unmet).to be_empty
43
+ end
44
+ end
@@ -50,7 +50,7 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
50
50
  PWN::Env[:ai][:active] = :openai
51
51
  PWN::Env[:ai][:openai] = { key: 'offline-fixture-key' }
52
52
  @agent_cfg[:plan_first] = false
53
- runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Enclosing request', session_id: 'enclosing', artifact_root: @tmp, trace: false)
53
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Enclosing request', session_id: 'enclosing', artifact_root: File.join(@tmp, 'controller'), trace: false)
54
54
  expect(runtime).not_to receive(:call)
55
55
  Thread.current[:pwn_request_runtime] = runtime
56
56
  allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
@@ -78,7 +78,8 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
78
78
  console.instance_variable_set(:@mission_options, { workspace: @workspace, worktree: true, rounds: 1, verification_timeout: 60 })
79
79
  out = Timeout.timeout(15) do
80
80
  console.submit('/swarm mission Write hello to greeting.txt')
81
- controller.send(:workers).last.value
81
+ controller.send(:workers).last.join
82
+ controller.snapshot[:jobs].last[:team]
82
83
  end
83
84
  expect(out).to include(status: 'accepted')
84
85
  expect(out[:evidence].first).to include(stdout: 'hello', exitstatus: 0)
@@ -95,6 +96,7 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
95
96
  expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
96
97
  expect(JSON.parse(File.read(out[:record_path])).dig('application', 'status')).to eq('applied')
97
98
  ensure
99
+ Thread.current[:pwn_request_runtime] = nil
98
100
  console&.send(:stop_worker)
99
101
  controller&.close
100
102
  if out && out[:worktree]
@@ -620,8 +622,8 @@ RSpec.describe 'Swarm solve' do # rubocop:disable Metrics/BlockLength -- end-to-
620
622
  end
621
623
 
622
624
  it 'pauses dispatch, resumes, and cancels through the coordinator command entry' do
623
- entered = Queue.new
624
- release = Queue.new
625
+ entered = PWNTestPipeQueue.new
626
+ release = PWNTestPipeQueue.new
625
627
  @hook = lambda do |handoff, _answer|
626
628
  if handoff[:phase] == 'assess'
627
629
  entered << true