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
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../support/harness/package_acceptance'
5
+ require_relative '../support/harness/cli_faults'
6
+
7
+ RSpec.describe 'harness process lifecycle' do
8
+ it 'preserves actual exit and stderr when startup fails before readiness' do
9
+ Dir.mktmpdir('pwn-harness-process-') do |root|
10
+ ready = File.join(root, 'ready')
11
+ command = [RbConfig.ruby, '-e', "warn 'unsupported architecture fixture'; exit 78"]
12
+ result = HarnessEvaluation.capture({ 'HOME' => root }, command, root, 1, ready_path: ready, startup_seconds: 2)
13
+ expect(result).to include(exit_status: 78, stderr: "unsupported architecture fixture\n", resource_cap: false, ready_observed: false, timeout_phase: nil)
14
+ end
15
+ end
16
+
17
+ it 'distinguishes a bounded boot timeout from an execution timeout' do
18
+ Dir.mktmpdir('pwn-harness-process-') do |root|
19
+ result = HarnessEvaluation.capture({ 'HOME' => root }, [RbConfig.ruby, '-e', 'sleep 60'], root, 1,
20
+ ready_path: File.join(root, 'ready'), startup_seconds: 0.05)
21
+ expect(result).to include(resource_cap: true, ready_observed: false, timeout_phase: 'startup', exit_status: nil, signal: Signal.list.fetch('KILL'))
22
+ end
23
+ end
24
+
25
+ it 'reports the real child failure when its fingerprint is missing, without inventing one' do
26
+ Dir.mktmpdir('pwn-harness-process-') do |root|
27
+ process = HarnessEvaluation.capture({ 'HOME' => root }, [RbConfig.ruby, '-e', "warn 'namespace unavailable'; exit 78"], root, 2)
28
+ File.write(File.join(root, 'initial-process.json'), JSON.generate(process))
29
+ row = { id: 'checkpoint-restart', artifact_root: root }
30
+ expect { HarnessPackageAcceptance.validate_fingerprints!([row], '/isolated/gems/pwn') }
31
+ .to raise_error(RuntimeError) { |error| expect(error.message).to include('checkpoint-restart', 'fingerprint', '"exit_status":78', 'namespace unavailable') }
32
+ expect(File.exist?(File.join(root, 'fingerprint.json'))).to be(false)
33
+ end
34
+ end
35
+
36
+ it 'records observed loaded components before an abrupt fixture exit bypasses at_exit' do
37
+ Dir.mktmpdir('pwn-harness-process-') do |root|
38
+ fixture = File.join(root, 'provider.json')
39
+ fingerprint = File.join(root, 'fingerprint.json')
40
+ File.write(fixture, JSON.generate(responses: []))
41
+ env = { 'HOME' => root, 'GEM_HOME' => Gem.dir, 'GEM_PATH' => Gem.path.join(File::PATH_SEPARATOR),
42
+ 'PWN_HARNESS_FIXTURE' => fixture, 'PWN_HARNESS_EVENTS' => File.join(root, 'events.jsonl'), 'PWN_HARNESS_FINGERPRINT' => fingerprint }
43
+ command = [RbConfig.ruby, '-I', File.join(HarnessEvaluation::ROOT, 'lib'), '-r',
44
+ File.join(HarnessEvaluation::ROOT, 'spec/support/harness/fake_provider.rb'), '-e', 'exit!(73)']
45
+ result = HarnessEvaluation.agent_capture(env, command, root, 5)
46
+ expect(result).to include(exit_status: 73, ready_observed: true)
47
+ expect(File.file?(fingerprint)).to be(true), result.inspect
48
+ expect(JSON.parse(File.read(fingerprint)).fetch('components')).not_to be_empty
49
+ end
50
+ end
51
+
52
+ it 'loads checkpoint controls across the real namespace exec boundary' do
53
+ Dir.mktmpdir('pwn-harness-process-') do |root|
54
+ fixture = File.join(root, 'provider.json')
55
+ runtime = File.join(root, 'runtime')
56
+ File.write(fixture, JSON.generate(responses: [{ fault: 'crash' }]))
57
+ env = { 'HOME' => root, 'PATH' => HarnessEvaluation.isolated_path, 'GEM_HOME' => Gem.dir, 'GEM_PATH' => Gem.path.join(File::PATH_SEPARATOR),
58
+ 'PWN_HARNESS_FIXTURE' => fixture, 'PWN_HARNESS_EVENTS' => File.join(root, 'events.jsonl'),
59
+ 'PWN_HARNESS_FINGERPRINT' => File.join(root, 'fingerprint.json'), 'PWN_HARNESS_RUNTIME' => runtime }
60
+ command = [RbConfig.ruby, '-I', File.join(HarnessEvaluation::ROOT, 'lib'), '-r',
61
+ File.join(HarnessEvaluation::ROOT, 'spec/support/harness/fault_preload.rb'), '-e',
62
+ 'PWN::AI::Agent::MissionNamespace.force_exec = true; load ARGV.shift', File.join(HarnessEvaluation::ROOT, 'bin/pwn-ai'), '--ai', 'Offline checkpoint fixture']
63
+ result = HarnessEvaluation.agent_capture(env, command, root, 10)
64
+ expect(result).to include(exit_status: 73)
65
+ expect(File.file?(File.join(runtime, 'identity.json'))).to be(true), result.inspect
66
+ end
67
+ end
68
+
69
+ it 'does not resume a checkpoint that never started after a real child failure' do
70
+ Dir.mktmpdir('pwn-harness-process-') do |root|
71
+ failure = { exit_status: 78, resource_cap: false, ready_observed: false, stdout: '', stderr: 'namespace unavailable' }
72
+ allow(HarnessCLIFaults).to receive(:invoke).and_return(failure)
73
+ report = HarnessCLIFaults.run(output: root)
74
+ expect(HarnessCLIFaults).to have_received(:invoke).exactly(5).times
75
+ expect(report.fetch(:results)).to all(include(outcome: 'FAIL', exit_status: 78))
76
+ end
77
+ end
78
+
79
+ it 'redacts child diagnostics before reporting a missing fingerprint' do
80
+ Dir.mktmpdir('pwn-harness-process-') do |root|
81
+ row = { id: 'failed-boot', artifact_root: root, exit_status: 78, stderr: 'password=offline-secret' }
82
+ expect { HarnessPackageAcceptance.validate_fingerprints!([row], '/isolated/gems/pwn') }.to raise_error(RuntimeError) do |error|
83
+ expect(error.message).to include('REDACTED', '78')
84
+ expect(error.message).not_to include('offline-secret')
85
+ end
86
+ end
87
+ end
88
+
89
+ it 'requires fresh readiness on restart rather than accepting the previous process marker' do
90
+ Dir.mktmpdir('pwn-harness-process-') do |root|
91
+ File.write(File.join(root, 'harness-ready'), 'previous invocation')
92
+ command = [RbConfig.ruby, '-e', "sleep 0.2; puts 'booted'; STDOUT.flush; File.write(ENV.fetch('PWN_HARNESS_READY'), 'ready'); sleep 60"]
93
+ result = HarnessEvaluation.agent_capture({ 'HOME' => root }, command, root, 0.05)
94
+ expect(result).to include(resource_cap: true, ready_observed: true, stdout: "booted\n", timeout_phase: 'execution')
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+ require 'fileutils'
7
+ require 'json'
8
+
9
+ RSpec.describe 'mission cancellation through real subprocesses' do
10
+ def mission_fixture(name, *args)
11
+ Dir.mktmpdir('mission-cancel-') do |root|
12
+ home = File.join(root, 'home')
13
+ work = File.join(root, 'work')
14
+ FileUtils.mkdir_p([home, work])
15
+ fixture = File.expand_path("../support/harness/#{name}.rb", __dir__)
16
+ stdout, stderr, status = Open3.capture3({ 'HOME' => home }, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), fixture, work, *args, chdir: work)
17
+ expect(status.success?).to be(true), stderr
18
+ JSON.parse(stdout)
19
+ end
20
+ end
21
+
22
+ it 'cancels the legacy interactive entrypoint through a native terminal and accepts its next mission' do
23
+ require 'pty'
24
+ require 'timeout'
25
+ Dir.mktmpdir('mission-legacy-') do |root|
26
+ FileUtils.mkdir_p(File.join(root, 'home'))
27
+ fixture = File.expand_path('../support/harness/mission_legacy.rb', __dir__)
28
+ PTY.spawn({ 'HOME' => File.join(root, 'home') }, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), fixture, root, chdir: root) do |reader, writer, pid|
29
+ Timeout.timeout(15) { sleep 0.01 until File.exist?(File.join(root, 'ready')) }
30
+ writer.write("\x03")
31
+ output = +''
32
+ Timeout.timeout(15) do
33
+ loop { output << reader.readpartial(4096) }
34
+ rescue EOFError, Errno::EIO
35
+ nil
36
+ end
37
+ _child, status = Process.wait2(pid)
38
+ expect(status.success?).to be(true), output
39
+ row = output.lines.find { |line| line.start_with?('{') }
40
+ expect(JSON.parse(row)).to include('ready' => true, 'late' => false, 'second' => true, 'tty' => true)
41
+ ensure
42
+ begin
43
+ Process.kill('KILL', pid)
44
+ Process.wait(pid)
45
+ rescue Errno::ESRCH, Errno::ECHILD
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ it 'reaps provider execution before accepting the next console mission' do
53
+ expect(mission_fixture('mission_console')).to include('ready' => true, 'late' => false, 'second' => true, 'fenced' => true)
54
+ end
55
+
56
+ it 'hard cancels local command descendants before the next mission' do
57
+ expect(mission_fixture('mission_console', 'command')).to include('ready' => true, 'late' => false, 'second' => true, 'fenced' => true)
58
+ end
59
+
60
+ it 'keeps detached tool jobs alive until their enclosing mission exits' do
61
+ expect(mission_fixture('mission_job_lifetime')).to include('survived_tool' => true)
62
+ end
63
+
64
+ it 'hard cancels independent swarm provider and tool descendants' do
65
+ expect(mission_fixture('mission_swarm')).to include('ready' => true, 'late' => false)
66
+ end
67
+
68
+ it 'fails closed and reaps startup when namespace creation is denied' do
69
+ expect(mission_fixture('mission_unsupported')).to include('blocked' => true, 'actionable' => true, 'reaped' => true)
70
+ end
71
+
72
+ it 'owns double-forked jobs on normal exit without killing unrelated processes' do
73
+ expect(mission_fixture('mission_normal_exit')).to include('reaped' => true, 'late' => false, 'unrelated' => true)
74
+ end
75
+
76
+ it 'stops and reaps a setsid descendant before cancellation returns' do
77
+ expect(mission_fixture('mission_dispatch')).to include('cancelled' => true, 'descendant_started' => true, 'reaped' => true, 'post_cancel_write' => false)
78
+ end
79
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pty'
5
+ require 'io/console'
6
+ require 'timeout'
7
+
8
+ RSpec.describe 'native curses mission cancellation' do
9
+ %w[ordinary command].each do |mode|
10
+ it "handles terminal CTRL+C during #{mode} execution and accepts the next mission without late writes" do
11
+ Dir.mktmpdir('mission-curses-') do |root|
12
+ home = File.join(root, 'home')
13
+ work = File.join(root, 'work')
14
+ FileUtils.mkdir_p([home, work])
15
+ fixture = File.expand_path('../support/harness/mission_curses_pty.rb', __dir__)
16
+ PTY.spawn({ 'HOME' => home, 'TERM' => 'xterm-256color' }, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), fixture, work, mode, chdir: work) do |reader, writer, pid|
17
+ writer.winsize = [32, 120]
18
+ output = +''
19
+ pump = Thread.new do
20
+ loop { output << reader.readpartial(4096) }
21
+ rescue EOFError, Errno::EIO
22
+ nil
23
+ end
24
+ %w[idle ready cancelled second].zip([mode == 'command' ? "/mcp fixture\r" : "first\r", "\x03", "second\r", "back\r"]).each do |marker, keys|
25
+ Timeout.timeout(20) { sleep 0.01 until File.exist?(File.join(work, marker)) }
26
+ writer.write(keys)
27
+ end
28
+ Timeout.timeout(20) { pump.join }
29
+ _child, status = Process.wait2(pid)
30
+ expect(status.success?).to be(true), output
31
+ expect(JSON.parse(output[/\{"ready":.*\}/])).to include('ready' => true, 'late' => false, 'second' => true, 'tty' => true)
32
+ ensure
33
+ begin
34
+ Process.kill('KILL', pid)
35
+ Process.wait(pid)
36
+ rescue Errno::ESRCH, Errno::ECHILD
37
+ nil
38
+ end
39
+ pump&.join(1)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'README security overview', :aggregate_failures do
6
+ let(:root) { File.expand_path('../..', __dir__) }
7
+ let(:readme) { File.read(File.join(root, 'README.md')) }
8
+ let(:security) do
9
+ readme[/^##### \*\*Vault and Sandboxing\*\* #####\n(.*?)(?=^#### )/m, 1].to_s.gsub(/\s+/, ' ')
10
+ end
11
+
12
+ it 'links a security subsection under Why PWN without replacing How PWN Works' do
13
+ expect(readme).to include('[Vault and Sandboxing](#vault-and-sandboxing)')
14
+ expect(readme).to match(/^#### \*\*Why PWN\*\* ####.*^##### \*\*Vault and Sandboxing\*\* #####.*^#### \*\*How PWN Works\*\* ####/m)
15
+ end
16
+
17
+ it 'explains credential handling at the tool boundary and engagement loot scope' do
18
+ expect(security).to include('security research', 'bug bounty hunting', 'penetration testing',
19
+ 'at rest', '{{vault:label}}', 'Dispatch', 'tool boundary',
20
+ 'prompts', 'logs', 'engagement', 'provenance', 'across engagements')
21
+ end
22
+
23
+ it 'documents the mandatory local boundary independently of optional sandbox profiles' do
24
+ expect(security).to include('untrusted PoCs', 'prompt injection', '~/.pwn/workspaces/runtime',
25
+ 'original operator request', 'parent', 'source', 'controller state',
26
+ 'read-only even if named', 'fail closed', 'Landlock', 'seccomp',
27
+ 'namespace', '`strict`', '`permissive`', '`off`')
28
+ end
29
+
30
+ it 'keeps encryption, provider privacy and operator responsibilities distinct' do
31
+ expect(security).to include('active process', 'LLM-provider privacy', 'whole-filesystem',
32
+ 'evidence/artifact encryption', 'redaction can miss',
33
+ 'not authorization', 'network isolation', 'perfect container',
34
+ 'guarantee against bugs', 'target authorization', 'least-privilege',
35
+ 'retention')
36
+ end
37
+
38
+ it 'links implementation and boundary documentation with existing local targets' do
39
+ expect(security).to include('(lib/pwn/plugins/vault.rb)', '(lib/pwn/ai/agent/dispatch.rb)',
40
+ '(documentation/Runtime-Source-Protection.md)',
41
+ '(documentation/Mission-Cancellation.md)',
42
+ '(documentation/Configuration.md)', '(documentation/YubiKey-Vaults.md)')
43
+ refs = readme.scan(/\]\((?!https?:|#|mailto:)([^)\s]+)\)/).flatten.uniq
44
+ broken = refs.reject { |ref| File.exist?(File.expand_path(ref.split('#', 2).first, root)) }
45
+ expect(broken).to be_empty, "broken README relative links: #{broken.join(', ')}"
46
+ end
47
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'pwn_web_cache_deception YubiKey options' do
6
+ let(:script) { File.expand_path('../../bin/pwn_web_cache_deception', __dir__) }
7
+ let(:browser) { double('offline browser', goto: nil, html: '<html>offline</html>') }
8
+ let(:browser_obj) { { browser: browser } }
9
+ let(:provider) { double('offline WWW provider', open: browser_obj, login: browser_obj, logout: browser_obj, close: nil) }
10
+ let(:driver_opts) { {} }
11
+ let(:account) { 'Example:tester@example.invalid' }
12
+ let(:serial) { '00123456' }
13
+ let(:args) do
14
+ ['--target-url=https://example.invalid/test', "--output-results=#{File.join(@tmpdir, 'results.txt')}",
15
+ '--pwn-www-mod=GitHub', '--username=tester@example.invalid', '--password=fake-password',
16
+ "--yubikey-account=#{account}", '--yubikey-confirm', "--yubikey-serial=#{serial}", '--no-mfa']
17
+ end
18
+
19
+ around do |example|
20
+ Dir.mktmpdir('pwn-wcd-yubikey-') do |dir|
21
+ @tmpdir = dir
22
+ example.run
23
+ end
24
+ end
25
+
26
+ before do
27
+ stub_const('ARGV', args.dup)
28
+ allow(PWN::Env).to receive(:[]).and_call_original
29
+ allow(PWN::Env).to receive(:[]).with(:driver_opts).and_return(driver_opts)
30
+ allow(PWN::Config).to receive(:refresh_env)
31
+ stub_const('PWN::WWW::GitHub', provider)
32
+ stub_const('PWN::Plugins::YubiKey', double('offline device boundary'))
33
+ stub_const('PWN::Plugins::TransparentBrowser', double('offline browser boundary', close: nil))
34
+ allow(PWN::Plugins::AuthenticationHelper).to receive(:mask_password).and_raise('unexpected password prompt')
35
+ end
36
+
37
+ it 'forwards exact account and serial options to GitHub login even with --no-mfa' do
38
+ expected = { browser_obj: browser_obj, username: 'tester@example.invalid', password: 'fake-password', mfa: false, yubikey: { account: account, confirm: true, serial: serial } }
39
+ expect(provider).to receive(:login).with(expected).and_return(browser_obj)
40
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
41
+ load script, true
42
+ end
43
+
44
+ %w[Synack Twitter BugCrowd CoinbasePro AppCobaltIO].each do |name|
45
+ it "forwards YubiKey options only at #{name} login, not browser open" do
46
+ ARGV.map! { |arg| arg == '--pwn-www-mod=GitHub' ? "--pwn-www-mod=#{name}" : arg }
47
+ stub_const("PWN::WWW::#{name}", provider)
48
+ expect(provider).to receive(:open).with(browser_type: :chrome).and_return(browser_obj)
49
+ expect(provider).to receive(:login).with(hash_including(yubikey: { account: account, confirm: true, serial: serial }, mfa: false)).and_return(browser_obj)
50
+ load script, true
51
+ end
52
+ end
53
+
54
+ %w[Google Paypal].each do |name|
55
+ it "rejects YubiKey for #{name} before password prompts or opening a browser" do
56
+ ARGV.map! { |arg| arg == '--pwn-www-mod=GitHub' ? "--pwn-www-mod=#{name}" : arg }
57
+ ARGV.delete('--password=fake-password')
58
+ stub_const("PWN::WWW::#{name}", provider)
59
+ expect(provider).not_to receive(:open)
60
+ expect(provider).not_to receive(:login)
61
+ expect { load script, true }.to raise_error(ArgumentError, /YubiKey OATH.*supported/)
62
+ end
63
+ end
64
+
65
+ it 'requires a selected compatible provider before prompting or opening a browser' do
66
+ ARGV.delete('--pwn-www-mod=GitHub')
67
+ ARGV.delete('--password=fake-password')
68
+ expect { load script, true }.to raise_error(ArgumentError, /YubiKey OATH.*supported/)
69
+ end
70
+
71
+ it 'rejects a serial without an exact account instead of silently disabling YubiKey' do
72
+ ARGV.delete("--yubikey-account=#{account}")
73
+ ARGV.delete('--password=fake-password')
74
+ expect(provider).not_to receive(:open)
75
+ expect { load script, true }.to raise_error(ArgumentError, /serial.*account/)
76
+ end
77
+
78
+ it 'requires --yubikey-confirm before password prompts or browser open' do
79
+ ARGV.delete('--yubikey-confirm')
80
+ ARGV.delete('--password=fake-password')
81
+ expect(provider).not_to receive(:open)
82
+ expect(provider).not_to receive(:login)
83
+ expect { load script, true }.to raise_error(ArgumentError, /confirm: true.*HOTP.*authenticat/i)
84
+ end
85
+
86
+ it 'rejects confirmation without an account before password prompts or browser open' do
87
+ ARGV.reject! { |arg| arg.start_with?('--yubikey-account=', '--yubikey-serial=') || arg == '--password=fake-password' }
88
+ expect(provider).not_to receive(:open)
89
+ expect(provider).not_to receive(:login)
90
+ expect { load script, true }.to raise_error(ArgumentError, /--yubikey-confirm.*--yubikey-account/)
91
+ end
92
+
93
+ it 'does not accept a string value for the boolean --yubikey-confirm flag' do
94
+ ARGV.map! { |arg| arg == '--yubikey-confirm' ? '--yubikey-confirm=true' : arg }
95
+ expect(provider).not_to receive(:open)
96
+ expect { load script, true }.to raise_error(OptionParser::NeedlessArgument)
97
+ end
98
+
99
+ ['', '123x', ' 123', '-1'].each do |invalid|
100
+ it "rejects malformed serial #{invalid.inspect} before prompting or opening a browser" do
101
+ ARGV.map! { |arg| arg == "--yubikey-serial=#{serial}" ? "--yubikey-serial=#{invalid}" : arg }
102
+ ARGV.delete('--password=fake-password')
103
+ expect(provider).not_to receive(:open)
104
+ expect { load script, true }.to raise_error(ArgumentError, /serial.*decimal/)
105
+ end
106
+ end
107
+
108
+ it 'validates an empty account before password prompts or browser open' do
109
+ ARGV.map! { |arg| arg == "--yubikey-account=#{account}" ? '--yubikey-account=' : arg }
110
+ ARGV.delete('--yubikey-confirm')
111
+ ARGV.delete('--password=fake-password')
112
+ expect(provider).not_to receive(:open)
113
+ expect { load script, true }.to raise_error(ArgumentError, /account/)
114
+ end
115
+
116
+ it 'preserves an explicit MFA token from driver options rather than coercing it to true' do
117
+ ARGV.reject! { |arg| arg.start_with?('--yubikey-') || arg == '--no-mfa' }
118
+ driver_opts[:mfa] = '654321'
119
+ expect(provider).to receive(:login).with(hash_including(mfa: '654321')).and_return(browser_obj)
120
+ expect { load script, true }.not_to output(/fake-password|654321/).to_stdout
121
+ expect(File.read(File.join(@tmpdir, 'results.txt'))).not_to match(/fake-password|654321/)
122
+ end
123
+
124
+ it 'keeps the generic driver options unchanged when no YubiKey is requested' do
125
+ ARGV.reject! { |arg| arg.start_with?('--yubikey-') }
126
+ ARGV.map! { |arg| arg == '--pwn-www-mod=GitHub' ? '--pwn-www-mod=Google' : arg }
127
+ stub_const('PWN::WWW::Google', provider)
128
+ expect(provider).to receive(:login) do |options|
129
+ expect(options).not_to have_key(:yubikey)
130
+ expect(options[:mfa]).to be(false)
131
+ browser_obj
132
+ end
133
+ load script, true
134
+ end
135
+
136
+ it 'preserves the exact OATH name when the optional serial is omitted' do
137
+ exact = " #{account} "
138
+ ARGV.reject! { |arg| arg.start_with?('--yubikey-serial=') }
139
+ ARGV.map! { |arg| arg == "--yubikey-account=#{account}" ? "--yubikey-account=#{exact}" : arg }
140
+ expect(provider).to receive(:login).with(hash_including(yubikey: { account: exact, confirm: true })).and_return(browser_obj)
141
+ expect { load script, true }.not_to output(/fake-password|012345/).to_stdout
142
+ end
143
+
144
+ it 'documents account, consent risks, and serial flags with the supported-provider boundary' do
145
+ ARGV.replace(['--help'])
146
+ expect do
147
+ expect { load script, true }.to raise_error(SystemExit) { |error| expect(error.status).to eq(0) }
148
+ end.to output(/--yubikey-account.*GitHub.*Synack.*Twitter.*BugCrowd.*CoinbasePro.*AppCobaltIO.*Paypal.*--yubikey-confirm.*HOTP.*delete.*corrupt.*--yubikey-serial/m).to_stdout
149
+ end
150
+
151
+ it 'rejects an explicit token conflicting with YubiKey before password prompts' do
152
+ ARGV.delete('--no-mfa')
153
+ ARGV.delete('--yubikey-confirm')
154
+ ARGV.delete('--password=fake-password')
155
+ driver_opts[:mfa] = '654321'
156
+ expect(provider).not_to receive(:open)
157
+ expect { load script, true }.to raise_error(ArgumentError, /yubikey.*mfa/i)
158
+ end
159
+ end
@@ -6,18 +6,21 @@ require 'json'
6
6
  describe PWN::AI::Agent::Dispatch do
7
7
  it 'does not replay a timed-out real shell mutation after restoring its request checkpoint' do
8
8
  Dir.mktmpdir do |root|
9
- options = { request: 'Write counter', session_id: 'timeout', request_id: 'fixture', artifact_root: root, trace: false }
9
+ work = File.join(root, 'work')
10
+ FileUtils.mkdir_p(work)
11
+ allow(PWN::Plugins::AISandbox).to receive(:workspace).and_return(work)
12
+ options = { request: 'Write counter', session_id: 'timeout', request_id: 'fixture', artifact_root: File.join(root, 'control'), trace: false }
10
13
  runtime = PWN::AI::Agent::RequestRuntime.new(options)
11
14
  Thread.current[:pwn_request_runtime] = runtime
12
15
  PWN::AI::Agent::Registry.discover
13
- call = { id: 'mutation', function: { name: 'shell', arguments: { command: "printf effect >> #{root}/counter; sleep 3", timeout: 1 } } }
16
+ call = { id: 'mutation', function: { name: 'shell', arguments: { command: "printf effect >> #{work}/counter; sleep 3", timeout: 1 } } }
14
17
  first = JSON.parse(described_class.call(tool_call: call, scope_policy: { enabled: false }))
15
18
  expect(first.dig('result', 'error')).to include('timeout')
16
19
  Thread.current[:pwn_request_runtime] = PWN::AI::Agent::RequestRuntime.new(options.merge(resume: true))
17
20
  second = JSON.parse(described_class.call(tool_call: call, scope_policy: { enabled: false }))
18
21
  expect(second['success']).to be false
19
22
  expect(second.dig('result', 'error')).to eq('uncertain_side_effect')
20
- expect(File.read(File.join(root, 'counter'))).to eq('effect')
23
+ expect(File.read(File.join(work, 'counter'))).to eq('effect')
21
24
  expect(call.dig(:function, :arguments, :timeout)).to eq(1)
22
25
  end
23
26
  ensure
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+
6
+ RSpec.describe PWN::AI::Agent::Dispatch, 'vault access safety' do
7
+ before do
8
+ @calls = []
9
+ @result = { status: 'fixture-complete' }
10
+ handler = lambda do |args|
11
+ @calls << args
12
+ @result
13
+ end
14
+ entry = PWN::AI::Agent::Registry::Entry.new(name: 'vault_fixture', schema: { parameters: { type: 'object' } }, handler: handler)
15
+ allow(PWN::AI::Agent::Registry).to receive(:lookup).with(name: 'vault_fixture').and_return(entry)
16
+ allow(PWN::AI::Agent::Manifest).to receive(:load).and_return({})
17
+ allow(PWN::AI::Agent::Manifest).to receive(:check).and_return(nil)
18
+ allow(PWN::AI::Agent::ToolGuard).to receive_messages(scope_check!: nil, policy_decision: nil, canary_leak?: false, refuse_copied_persist?: false)
19
+ allow(PWN::AI::Agent::Engagement).to receive(:deny_if_out_of_scope).and_return(nil)
20
+ allow(PWN::AI::Agent::Mission).to receive(:active).and_return(nil)
21
+ allow(PWN::AI::Agent::Confirmation).to receive(:gate).and_return(nil)
22
+ allow(PWN::Plugins::Vault).to receive(:expand) { |opts| opts[:text] }
23
+ allow(PWN::Plugins::Vault).to receive(:redact) { |opts| opts[:text] }
24
+ end
25
+
26
+ it 'expands nested vault references once per leaf before invoking the handler' do
27
+ allow(PWN::Plugins::Vault).to receive(:expand).with(text: '{{vault:fixture}}').and_return('fixture-unlocked')
28
+ arguments = { nested: [{ secret: '{{vault:fixture}}' }], slot: 2 }
29
+ response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: arguments } }))
30
+ expect(response['success']).to be(true)
31
+ expect(@calls).to eq([{ nested: [{ secret: 'fixture-unlocked' }], slot: 2 }])
32
+ expect(PWN::Plugins::Vault).to have_received(:expand).with(text: '{{vault:fixture}}').once
33
+ expect(arguments[:nested].first[:secret]).to eq('{{vault:fixture}}')
34
+ end
35
+
36
+ it 'blocks a nested vault access failure without exposing its exception text' do
37
+ allow(PWN::Plugins::Vault).to receive(:expand).with(text: '{{vault:fixture}}').and_raise(IOError, 'fixture-private-detail')
38
+ response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: { nested: ['{{vault:fixture}}'] } } }))
39
+ expect(response).to include('success' => false, 'code' => 'VAULT_EXPANSION_FAILED')
40
+ expect(@calls).to be_empty
41
+ expect(response.to_s).not_to include('fixture-private-detail')
42
+ end
43
+
44
+ it 'withholds results when vault output redaction fails after handler execution' do
45
+ allow(PWN::Plugins::Vault).to receive(:redact).and_raise(IOError, 'fixture-private-detail')
46
+ response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: {} } }))
47
+ expect(@calls.length).to eq(1)
48
+ expect(response).to include('success' => false, 'code' => 'VAULT_REDACTION_FAILED', 'handler_executed' => true)
49
+ expect(response.to_s).not_to include('fixture-private-detail', 'fixture-complete')
50
+ expect(response).not_to have_key('result')
51
+ end
52
+
53
+ it 'redacts secrets in array results such as loot offers before returning them' do
54
+ @result = [{ secret: 'fixture-private-value' }]
55
+ allow(PWN::Plugins::Vault).to receive(:redact) { |opts| opts[:text].gsub('fixture-private-value', '{{vault:fixture}}') }
56
+ response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: {} } }))
57
+ expect(response).to include('success' => true, 'result' => [{ 'secret' => '{{vault:fixture}}' }])
58
+ expect(response.to_s).not_to include('fixture-private-value')
59
+ end
60
+
61
+ it 'blocks a real hardware-envelope expansion when the offline device fixture fails' do
62
+ Dir.mktmpdir('pwn-dispatch-yubikey-') do |root|
63
+ allow(Dir).to receive(:home).and_return(root)
64
+ allow(PWN::AI::Agent::Mission).to receive(:active_id).and_return(nil)
65
+ device = double('offline YubiKey fixture')
66
+ stub_const('PWN::Plugins::YubiKey', device)
67
+ allow(device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge)) }
68
+ PWN::Plugins::Vault.store(label: 'fixture', secret: 'fixture-private-value', yubikey: { slot: 2 })
69
+ allow(PWN::Plugins::Vault).to receive(:expand).and_call_original
70
+ allow(device).to receive(:challenge_response).and_raise(IOError, 'fixture-private-detail')
71
+ response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: { password: '{{vault:fixture}}' } } }))
72
+ expect(response).to include('success' => false, 'code' => 'VAULT_EXPANSION_FAILED')
73
+ expect(@calls).to be_empty
74
+ expect(response.to_s).not_to include('fixture-private-detail', 'fixture-private-value')
75
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
76
+ end
77
+ end
78
+
79
+ it 'never invokes a real handler after expansion of a vault argument fails' do
80
+ allow(PWN::Plugins::Vault).to receive(:expand).with(text: '{{vault:fixture}}').and_raise(IOError, 'device absent; fixture-private-detail')
81
+ response = JSON.parse(described_class.call(tool_call: { function: { name: 'vault_fixture', arguments: { password: '{{vault:fixture}}' } } }))
82
+ expect(@calls).to be_empty
83
+ expect(response).to include('success' => false, 'code' => 'VAULT_EXPANSION_FAILED')
84
+ expect(response.to_s).not_to include('fixture-private-detail')
85
+ end
86
+ end
@@ -34,6 +34,7 @@ describe 'indirect prompt-injection guards' do
34
34
  it 'does not re-infer the acceptance contract on a nested loop' do
35
35
  Thread.current[:pwn_loop_active] = true
36
36
  Thread.current[:pwn_loop_nested] = true
37
+ PWN::AI::Agent::TurnFinalizer.commit_artifacts!(request: 'Write /tmp/frozen.pdf')
37
38
  Thread.current[:pwn_loop_deliverables] = {
38
39
  paths: ['/tmp/frozen.pdf'], min_seconds: 0, skills: [], proofs: [], hosts: []
39
40
  }
@@ -44,6 +45,7 @@ describe 'indirect prompt-injection guards' do
44
45
  ensure
45
46
  Thread.current[:pwn_loop_active] = nil
46
47
  Thread.current[:pwn_loop_nested] = nil
48
+ Thread.current[:pwn_artifact_contract] = nil
47
49
  Thread.current[:pwn_loop_deliverables] = nil
48
50
  end
49
51
 
@@ -89,7 +89,7 @@ describe PWN::AI::Agent::Learning do
89
89
  expect(described_class.evidence_complete?(row: row)).to be(true)
90
90
  expect(File.binread(@source)).to eq(original_source)
91
91
  migration = Digest::SHA256.hexdigest(@original)
92
- backup = File.join(@root, 'learning', 'migrations', "#{migration}.backup.jsonl")
92
+ backup = JSON.parse(File.read(result[:ledger])).fetch('backup')
93
93
  expect(File.binread(backup)).to eq(@original.b)
94
94
  expect(described_class.rollback_evidence(migration: migration)[:restored]).to be(true)
95
95
  expect(File.binread(@learning)).to eq(@original.b)
@@ -778,14 +778,14 @@ describe 'PWN::AI::Agent::Learning conflicted outcomes' do
778
778
  end
779
779
  end
780
780
 
781
- it 'prunes bak files down to max_baks' do
781
+ it 'leaves legacy siblings untouched and reports the global seven-folder policy' do
782
782
  Dir.mktmpdir do |dir|
783
783
  stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(dir, 'learning.jsonl'))
784
784
  File.write(File.join(dir, 'learning.jsonl'), "{}\n")
785
785
  7.times { |i| File.write(File.join(dir, "learning.jsonl.bak-#{i}"), 'x') }
786
786
  out = PWN::AI::Agent::Learning.compact!(max_baks: 5)
787
- expect(Dir[File.join(dir, '*.bak*')].length).to eq(5)
788
- expect(out[:pruned]).to eq(2)
787
+ expect(Dir[File.join(dir, '*.bak*')].length).to eq(7)
788
+ expect(out).to include(pruned: 0, max_baks: 7, scope: 'global')
789
789
  end
790
790
  end
791
791
  end
@@ -21,6 +21,11 @@ RSpec.describe PWN::AI::Agent::Loop do
21
21
  allow(PWN::AI::Agent::Mistakes).to receive(:record)
22
22
  end
23
23
 
24
+ it 'never adopts cached model destinations absent from the original request' do
25
+ Thread.current[:pwn_loop_deliverables] = { paths: ['/tmp/invented.pdf'] }
26
+ expect(described_class.send(:declared_deliverables, request: 'Generate a report')).to eq([])
27
+ end
28
+
24
29
  it 'does not let source read evidence bypass the destination contract' do
25
30
  Dir.mktmpdir do |dir|
26
31
  source = File.join(dir, 'source.md')