pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -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')