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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'stringio'
6
+
7
+ RSpec.describe PWN::Migrate, 'hardware vault migrations' do
8
+ around do |example|
9
+ Dir.mktmpdir('migrate-yubikey-') do |root|
10
+ @root = root
11
+ example.run
12
+ end
13
+ end
14
+
15
+ before do
16
+ allow(Dir).to receive(:home).and_return(@root)
17
+ stub_const('PWN::Migrate::PWN_ROOT', @root)
18
+ stub_const('PWN::Env', { driver_opts: {} })
19
+ stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
20
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
21
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
22
+ end
23
+ @path = File.join(@root, 'pwn.yaml')
24
+ @original = { ai: { active: 'ollama', ollama: { model: 'fixture-model' } }, retained: 'fixture-value' }
25
+ File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original), yubikey: { slot: 2 }))
26
+ @io = StringIO.new
27
+ end
28
+
29
+ it 'backfills through an atomic encrypted update, preserving the hardware format and user choices' do
30
+ expect(File).not_to receive(:write).with(@path, anything)
31
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
32
+ result = described_class.backfill_vault(io: @io)
33
+ expect(result[:added]).to be_positive
34
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
35
+ document = PWN::Plugins::Vault.dump(file: @path)
36
+ expect(document.dig(:ai, :ollama, :model)).to eq('fixture-model')
37
+ expect(document[:retained]).to eq('fixture-value')
38
+ expect(document.dig(:ai, :agent)).to eq(PWN::Config.env_template.dig(:ai, :agent))
39
+ bytes = File.binread(@path)
40
+ expect(described_class.backfill_vault(io: @io)[:added]).to eq(0)
41
+ expect(File.binread(@path)).to eq(bytes)
42
+ expect(Dir.children(@root)).to eq(['pwn.yaml'])
43
+ end
44
+
45
+ it 'uses driver-selected hardware vault paths without touching the canonical vault' do
46
+ custom = File.join(@root, 'selected-vault')
47
+ FileUtils.cp(@path, custom)
48
+ PWN::Env[:driver_opts] = { pwn_env_path: custom, pwn_dec_path: File.join(@root, 'absent-sidecar') }
49
+ canonical = File.binread(@path)
50
+ expect(described_class.backfill_vault(io: @io)[:added]).to be_positive
51
+ expect(described_class.vault_drift[:missing]).to be_empty
52
+ expect(File.binread(@path)).to eq(canonical)
53
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: custom)).to be(true)
54
+ end
55
+
56
+ it 'honors an explicit independent legacy decryptor and vault over driver defaults' do
57
+ custom = File.join(@root, 'legacy-vault')
58
+ sidecar = File.join(@root, 'separate-legacy-decryptor')
59
+ File.write(custom, YAML.dump(@original))
60
+ PWN::Plugins::Vault.create(file: custom, decryptor_file: sidecar)
61
+ canonical = File.binread(@path)
62
+ credentials = YAML.safe_load_file(sidecar, symbolize_names: true)
63
+ result = described_class.backfill_vault(pwn_env_path: custom, pwn_dec_path: sidecar, io: @io)
64
+ expect(result[:added]).to be_positive
65
+ expect(PWN::Plugins::Vault.dump(credentials.merge(file: custom)).dig(:ai, :agent)).to be_a(Hash)
66
+ expect(File.binread(@path)).to eq(canonical)
67
+ end
68
+
69
+ it 'forwards explicit vault selection and hardware overrides through numbered migrations and fix runs' do
70
+ custom = File.join(@root, 'selected-vault')
71
+ FileUtils.cp(@path, custom)
72
+ canonical = File.binread(@path)
73
+ allow(PWN::Backups).to receive(:active?).and_return(true)
74
+ allow(described_class).to receive(:status).and_return(schema_installed: 3, schema_current: 4, pwn_version: 'fixture', incompatible: [])
75
+ allow(described_class).to receive(:stamp!)
76
+ stub_const('PWN::Migrate::SCHEMA_VERSION', 4)
77
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
78
+ raise IOError, 'selector was not forwarded' unless opts[:device_path] == '/fixture/native-device'
79
+
80
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
81
+ end
82
+ [false, true].each do |fix|
83
+ File.binwrite(custom, canonical)
84
+ allow(described_class).to receive(:status).and_return(schema_installed: fix ? 4 : 3, schema_current: 4, pwn_version: 'fixture', incompatible: [])
85
+ result = described_class.run(fix: fix, backup: false, io: @io, pwn_env_path: custom, yubikey: { device_path: '/fixture/native-device' })
86
+ expect(result[:applied_migrations]).to eq(fix ? [] : [4])
87
+ document = PWN::Plugins::Vault.dump(file: custom, yubikey: { device_path: '/fixture/native-device' })
88
+ expect(document.dig(:ai, :agent)).to be_a(Hash)
89
+ expect(File.binread(@path) == canonical).to be(true)
90
+ end
91
+ end
92
+
93
+ it 'reports the explicitly selected vault rather than a missing canonical file during migration' do
94
+ custom = File.join(@root, 'selected-vault')
95
+ File.rename(@path, custom)
96
+ stub_const('PWN::Migrate::SCHEMA_FILE', File.join(@root, '.schema'))
97
+ File.write(described_class::SCHEMA_FILE, JSON.generate(schema: described_class::SCHEMA_VERSION))
98
+ allow(PWN::Backups).to receive(:active?).and_return(true)
99
+ result = described_class.run(fix: true, backup: false, io: @io, pwn_env_path: custom)
100
+ expect(result[:before][:incompatible]).to be_empty
101
+ expect(result[:after][:incompatible]).to be_empty
102
+ expect(result[:after][:files].find { |row| row[:rel] == 'pwn.yaml' }[:path]).to eq(custom)
103
+ expect(File).not_to exist(@path)
104
+ end
105
+
106
+ it 'does not write during a dry run or when hardware encryption fails or is interrupted' do
107
+ bytes = File.binread(@path)
108
+ expect(described_class.backfill_vault(dry_run: true, io: @io)[:added]).to be_positive
109
+ expect(File.binread(@path)).to eq(bytes)
110
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic device unavailable')
111
+ expect(described_class.backfill_vault(io: @io)).to be_nil
112
+ expect(File.binread(@path)).to eq(bytes)
113
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(Interrupt)
114
+ expect { described_class.backfill_vault(io: @io) }.to raise_error(Interrupt)
115
+ expect(File.binread(@path)).to eq(bytes)
116
+ expect(Dir.children(@root)).to eq(['pwn.yaml'])
117
+ end
118
+
119
+ it 'reports missing keys in a hardware vault without a software decryptor' do
120
+ bytes = File.binread(@path)
121
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
122
+ result = described_class.vault_drift
123
+ expect(result).to include(user: @original)
124
+ expect(result[:missing]).to include('ai.agent')
125
+ expect(File.binread(@path)).to eq(bytes)
126
+ expect(File).not_to exist("#{@path}.decryptor")
127
+ end
128
+ end
@@ -51,6 +51,14 @@ describe PWN::ModuleSkills do
51
51
  expect(described_class).to respond_to :help
52
52
  end
53
53
 
54
+ it 'identifies the file-owning class rather than its enclosing module or nested error class' do
55
+ lib = File.expand_path('../../../lib', __dir__)
56
+ rec = described_class.send(:parse_file, path: File.join(lib, 'pwn/ai/agent/mission_namespace.rb'), lib_root: lib).first
57
+ expect(rec[:const]).to eq('PWN::AI::Agent::MissionNamespace')
58
+ expect(rec[:source]).to eq('pwn/ai/agent/mission_namespace.rb')
59
+ expect(described_class.render(module: rec)).to include('module: PWN::AI::Agent::MissionNamespace')
60
+ end
61
+
54
62
  it 'maps a constant or source file to a namespace-mirrored SKILL.md path' do
55
63
  expect(described_class.relpath(const: 'PWN::Plugins::TransparentBrowser')).to eq(
56
64
  'pwn/plugins/transparent_browser/SKILL.md'
@@ -14,6 +14,236 @@ describe PWN::Plugins::AISandbox do
14
14
  expect(described_class).to respond_to :help
15
15
  end
16
16
 
17
+ it 'enforces read-only roots in real workers while preserving outside writes and host cwd' do
18
+ Dir.mktmpdir('pwn-boundary') do |root|
19
+ protected = File.join(root, 'source')
20
+ workspace = File.join(root, 'work')
21
+ FileUtils.mkdir_p([protected, workspace])
22
+ target = File.join(protected, 'keep')
23
+ File.write(target, 'original')
24
+ File.symlink(protected, File.join(workspace, 'alias'))
25
+ cwd = Dir.pwd
26
+ result = described_class.protect_call(roots: [protected], workspace: workspace, write_roots: [File.join(workspace, 'outside.txt')]) do
27
+ errors = [target, File.join(workspace, 'alias', 'keep'), File.join(protected, '..', 'source', 'new')].map do |path|
28
+ File.write(path, 'bad')
29
+ 'allowed'
30
+ rescue Errno::EACCES, Errno::EPERM
31
+ 'denied'
32
+ end
33
+ File.write('outside.txt', 'allowed')
34
+ { errors: errors, original: File.read(target), cwd: Dir.pwd }
35
+ end
36
+ expect(result).to include(errors: %w[denied denied denied], original: 'original', cwd: workspace)
37
+ expect(File.read(File.join(workspace, 'outside.txt'))).to eq('allowed')
38
+ expect(File.read(target)).to eq('original')
39
+ metadata = described_class.protect_call(roots: [protected], workspace: workspace) do
40
+ File.chmod(0o600, target)
41
+ { changed: true }
42
+ end
43
+ expect(metadata[:success]).to be false
44
+ expect(metadata[:error]).to match(/Read-only file system|Permission denied|Operation not permitted/)
45
+ expect(Dir.pwd).to eq(cwd)
46
+ end
47
+ end
48
+
49
+ it 'writes only in the runtime workspace unless the original request names another path' do
50
+ Dir.mktmpdir('pwn-boundary') do |root|
51
+ source = File.join(root, 'source')
52
+ workspace = File.join(root, 'runtime')
53
+ named = File.join(root, 'named', 'report.txt')
54
+ FileUtils.mkdir_p([source, workspace])
55
+ File.write(File.join(source, 'keep'), 'original')
56
+ outside = File.join(root, 'unstated.txt')
57
+ result = described_class.protect_call(roots: [source], workspace: workspace, write_roots: [named, File.join(source, 'keep')]) do
58
+ File.write(named, 'allowed')
59
+ unstated = begin
60
+ File.write(outside, 'no')
61
+ 'allowed'
62
+ rescue Errno::EACCES, Errno::EPERM
63
+ 'denied'
64
+ end
65
+ protected_write = begin
66
+ File.write(File.join(source, 'keep'), 'bad')
67
+ 'allowed'
68
+ rescue Errno::EACCES, Errno::EPERM
69
+ 'denied'
70
+ end
71
+ { named: File.read(named), unstated: unstated, protected_write: protected_write }
72
+ end
73
+ expect(result).to include(named: 'allowed', unstated: 'denied', protected_write: 'denied')
74
+ expect(File.read(File.join(source, 'keep'))).to eq('original')
75
+ expect(described_class.workspace).to eq(File.expand_path(File.join(Dir.home, '.pwn', 'workspaces', 'runtime')))
76
+ Dir.chdir(root) { expect(described_class.workspace).to eq(File.expand_path(File.join(Dir.home, '.pwn', 'workspaces', 'runtime'))) }
77
+ end
78
+ end
79
+
80
+ it 'allows mv onto an extensionless destination named by the original request' do
81
+ Dir.mktmpdir('pwn-boundary') do |root|
82
+ source = File.join(root, 'source')
83
+ workspace = File.join(root, 'runtime')
84
+ dest_dir = File.join(root, 'dest')
85
+ FileUtils.mkdir_p([source, workspace, dest_dir])
86
+ destination = File.join(dest_dir, 'test')
87
+ result = described_class.protect_call(roots: [source], workspace: workspace, write_roots: [workspace, destination]) do
88
+ File.write('foo', 'data')
89
+ moved = begin
90
+ File.rename('foo', destination)
91
+ 'allowed'
92
+ rescue Errno::EACCES, Errno::EPERM
93
+ 'denied'
94
+ end
95
+ { moved: moved, body: File.read(destination), still_dir: File.directory?(destination) }
96
+ end
97
+ expect(result).to include(moved: 'allowed', body: 'data', still_dir: false)
98
+ end
99
+ end
100
+
101
+ it 'creates a named file when the destination parent contains an unreadable directory' do
102
+ Dir.mktmpdir('pwn-boundary') do |root|
103
+ source = File.join(root, 'source')
104
+ workspace = File.join(root, 'runtime')
105
+ parent = File.join(root, 'dest')
106
+ hidden = File.join(parent, 'private')
107
+ destination = File.join(parent, 'test')
108
+ FileUtils.mkdir_p([source, workspace, parent, hidden])
109
+ File.chmod(0, hidden)
110
+ result = described_class.protect_call(roots: [source], workspace: workspace, write_roots: [destination]) do
111
+ created = begin
112
+ File.write(destination, 'data')
113
+ 'allowed'
114
+ rescue Errno::EACCES, Errno::EPERM
115
+ 'denied'
116
+ end
117
+ body = File.read(destination) if created == 'allowed'
118
+ { created: created, body: body }
119
+ end
120
+ expect(result).to include(created: 'allowed', body: 'data')
121
+ ensure
122
+ File.chmod(0o700, hidden) if hidden && File.directory?(hidden)
123
+ end
124
+ end
125
+
126
+ it 'fails closed on unsupported kernels and existing external hardlink aliases' do
127
+ Dir.mktmpdir('pwn-boundary') do |root|
128
+ source = File.join(root, 'source')
129
+ work = File.join(root, 'work')
130
+ FileUtils.mkdir_p([source, work])
131
+ path = File.join(source, 'protected')
132
+ File.write(path, 'original')
133
+ File.link(path, File.join(work, 'alias'))
134
+ result = described_class.protect_call(roots: [source], workspace: work) { File.write(File.join(work, 'alias'), 'bad') }
135
+ expect(result[:success]).to be false
136
+ expect(File.read(path)).to eq('original')
137
+ File.unlink(File.join(work, 'alias'))
138
+ allow(described_class).to receive(:repository_restrict!).and_raise(NotImplementedError, 'kernel unsupported')
139
+ expect { described_class.protect_call(roots: [source], workspace: work) { File.write(path, 'bad') } }.to raise_error(described_class::NotStarted, /kernel unsupported/)
140
+ expect(File.read(path)).to eq('original')
141
+ end
142
+ end
143
+
144
+ it 'keeps nested dispatch and detached subprocesses inside the inherited boundary' do
145
+ Dir.mktmpdir('pwn-boundary') do |root|
146
+ source = File.join(root, 'source')
147
+ work = File.join(root, 'work')
148
+ FileUtils.mkdir_p([source, work])
149
+ path = File.join(source, 'protected')
150
+ File.write(path, 'original')
151
+ jobs = File.join(work, 'jobs')
152
+ stub_const('PWN::Plugins::Jobs::JOBS_DIR', jobs)
153
+ result = described_class.protect_call(roots: [source], workspace: work, write_roots: [work]) do
154
+ PWN::AI::Agent::Registry.register(name: 'nested_boundary_fixture', toolset: 'test', schema: { name: 'nested_boundary_fixture', parameters: { type: 'object' } }, handler: ->(_args) { File.write(path, 'bad') })
155
+ nested = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'nested_boundary_fixture', arguments: '{}' } }))
156
+ job = PWN::Plugins::Jobs.start(command: "printf bad > #{path}; printf done > #{work}/done", max_runtime: 10)
157
+ { nested: nested, job: job }
158
+ end
159
+ expect(result.dig(:nested, :success)).to be(false), result.inspect
160
+ expect(result.dig(:job, :id)).to be_a(String), result.inspect
161
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
162
+ sleep 0.02 until File.exist?(File.join(work, 'done')) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
163
+ expect(File.read(File.join(work, 'done'))).to eq('done')
164
+ expect(File.read(path)).to eq('original')
165
+ end
166
+ end
167
+
168
+ it 'protects controller writes after a worker redirects checkpoint ancestry' do
169
+ Dir.mktmpdir('pwn-controller-boundary') do |root|
170
+ source = File.join(root, 'source')
171
+ work = File.join(root, 'work')
172
+ FileUtils.mkdir_p([source, work])
173
+ script = <<~RUBY
174
+ require 'pwn'
175
+ require 'json'
176
+ source, work = ARGV
177
+ PWN::Plugins::AISandbox.define_singleton_method(:protected_roots) { [source, '/opt/pwn'] }
178
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Create a report', session_id: 'boundary', artifact_root: File.join(work, 'state'), trace: false, retrieve: false)
179
+ Thread.current[:pwn_request_runtime] = runtime
180
+ directory = File.dirname(runtime.checkpoint_path)
181
+ PWN::AI::Agent::Registry.register(name: 'boundary_fixture', toolset: 'fixture', schema: { name: 'boundary_fixture', parameters: { type: 'object' } }, handler: lambda { |_args|
182
+ FileUtils.rm_rf(directory)
183
+ File.symlink(source, directory)
184
+ { redirected: true }
185
+ })
186
+ result = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'boundary_fixture', arguments: '{}' } }))
187
+ puts JSON.generate(changed: !Dir.children(source).empty?, result: result)
188
+ RUBY
189
+ command = [RbConfig.ruby, '-I', File.expand_path('lib'), '-e', script, source, work]
190
+ stdout, stderr, status = Open3.capture3({ 'HOME' => work }, *command)
191
+ expect(status.success?).to be(true), stderr
192
+ row = JSON.parse(stdout)
193
+ expect(row['changed']).to be(false), row.inspect
194
+ expect(row.dig('result', 'success')).to be(false)
195
+ expect(Dir.children(source)).to be_empty
196
+ end
197
+ end
198
+
199
+ it 'brokers actual spawn and ask without granting state redirection or handler bypass' do
200
+ Dir.mktmpdir('pwn-swarm-control') do |root|
201
+ work = File.join(root, 'work')
202
+ home = File.join(root, 'home')
203
+ FileUtils.mkdir_p([work, home])
204
+ tool = lambda do |command, id|
205
+ { role: 'assistant', content: '', tool_calls: [{ id: id, type: 'function', function: { name: 'shell', arguments: JSON.generate(command: command, timeout: 10) } }] }
206
+ end
207
+ fixture = File.join(work, 'provider.json')
208
+ File.write(fixture, JSON.generate(toolsets: %w[shell swarm], responses: [tool.call("printf 'alpha\\n' > result.txt", 'write'), tool.call('cat result.txt', 'read')] + Array.new(8) { { role: 'assistant', content: 'FIXTURE_COMPLETE', tool_calls: [] } }))
209
+ env = { 'HOME' => home, 'PWN_HARNESS_FIXTURE' => fixture, 'PWN_HARNESS_EVENTS' => File.join(work, 'events.jsonl'), 'PWN_HARNESS_FINGERPRINT' => File.join(work, 'fingerprint.json') }
210
+ command = [RbConfig.ruby, '-I', File.expand_path('lib'), '-r', File.expand_path('spec/support/harness/fake_provider.rb'), File.expand_path('spec/support/harness/swarm_control.rb'), work]
211
+ stdout, stderr, status = Open3.capture3(env, *command, chdir: work)
212
+ expect(status.success?).to be(true), "#{stderr}\n#{stdout}"
213
+ row = JSON.parse(stdout.lines.last)
214
+ expect(row.dig('spawn', 'success')).to be(true), row.inspect
215
+ expect(row.dig('ask', 'success')).to be(true), row.inspect
216
+ expect(row.dig('ask', 'result', 'reply')).to eq('FIXTURE_COMPLETE')
217
+ expect(row['bytes']).to eq("alpha\n")
218
+ expect(row['sentinel']).to eq('unchanged')
219
+ expect(row.dig('attack', 'result').values).to all(start_with('denied:'))
220
+ expect(row.dig('replacement', 'success')).to be(false)
221
+ expect(row.dig('observations', File.join(work, 'result.txt'), 'child_request_id')).to be_a(String)
222
+ persisted = YAML.safe_load_file(File.join(home, '.pwn', 'agents.yml'))
223
+ expect(persisted.fetch('fixture_worker').fetch('model')).to eq('offline-scripted-v1')
224
+ end
225
+ end
226
+
227
+ it 'pins controller directories across pathname replacement and rejects source ancestry' do
228
+ Dir.mktmpdir('pwn-control-fd') do |root|
229
+ source = File.join(root, 'source')
230
+ state = File.join(root, 'state')
231
+ moved = File.join(root, 'moved')
232
+ FileUtils.mkdir_p([source, state])
233
+ allow(described_class).to receive(:protected_roots).and_return([source])
234
+ described_class.control_directory(path: state) do |fd_path|
235
+ File.rename(state, moved)
236
+ File.symlink(source, state)
237
+ File.open(File.join(fd_path, 'receipt'), File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write('safe') }
238
+ end
239
+ expect(File.read(File.join(moved, 'receipt'))).to eq('safe')
240
+ expect(Dir.children(source)).to be_empty
241
+ expect { described_class.control_directory(path: state) { raise 'must not run' } }.to raise_error(Errno::ENOTDIR)
242
+ expect { described_class.control_directory(path: File.join(source, 'new')) { raise 'must not run' } }.to raise_error(described_class::NotStarted, /protected source/)
243
+ expect(Dir.children(source)).to be_empty
244
+ end
245
+ end
246
+
17
247
  it 'denies rm -rf /tmp/fake_root in strict mode without touching disk' do
18
248
  root = '/tmp/fake_root'
19
249
  FileUtils.mkdir_p(root)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::AuthenticationHelper, 'native YubiKey validation' do
6
+ before do
7
+ stub_const('PWN::Plugins::YubiKey', double('offline native device'))
8
+ expect(TTY::Prompt).not_to receive(:new)
9
+ end
10
+
11
+ [{ ykman: '/fixture/unsupported' }, { timeout: false }, { timeout: 0 }, { timeout: Float::INFINITY },
12
+ { reader: false }, { reader: '' }, { reader: "bad\nreader" }, { device_path: false }, { device_path: '' },
13
+ { device_path: "bad\0path" }, { serial: 123 }, { serial: false }, { serial: ' 123' },
14
+ { serial: '123', reader: 'Fixture reader' }, { reader: 'Fixture reader', device_path: '/fixture/native-device' }, { connection: false }, { connection: Object.new },
15
+ { password: false }, { password: '' }, { secret_provider: false }, { secret_provider: Object.new }].each do |invalid|
16
+ it "rejects #{invalid.keys.join('/')} controls before login, prompts or a device operation" do
17
+ selector = { account: 'Fixture:account', confirm: true }.merge(invalid)
18
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
19
+ expect { described_class.validate_mfa_options(yubikey: selector) }.to raise_error(ArgumentError, /yubikey/i)
20
+ expect { described_class.mfa(yubikey: selector) }.to raise_error(ArgumentError, /yubikey/i)
21
+ end
22
+ end
23
+
24
+ it 'forwards an exact native connection and in-memory password without inspecting or persisting them' do
25
+ connection = double('native connection', exchange: ''.b)
26
+ selector = { account: ' Fixture:account ', confirm: true, reader: ' Fixture reader ', connection: connection, password: 'fixture-only-password' }.freeze
27
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(selector).and_return('000123')
28
+ expect(described_class.mfa(yubikey: selector)).to eq('000123')
29
+ end
30
+
31
+ it 'does not invoke a secret provider during source selection and passes it unchanged to the native helper' do
32
+ provider = double('operator secret provider', call: 'fixture-only-password')
33
+ expect(provider).not_to receive(:call)
34
+ selector = { account: 'Fixture:account', confirm: true, serial: '001234', device_path: '/fixture/native-device', secret_provider: provider }.freeze
35
+ options = { yubikey: selector }.freeze
36
+ expect(described_class.validate_mfa_options(options)).to equal(options)
37
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(selector).and_return('00123456')
38
+ expect(described_class.mfa(options)).to eq('00123456')
39
+ end
40
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::AuthenticationHelper do
6
+ let(:yubikey) { { account: 'Example:tester@example.invalid', confirm: true, serial: '123456', device_path: '/fixture/native-device', timeout: 30 }.freeze }
7
+
8
+ before do
9
+ stub_const('PWN::Plugins::YubiKey', Module.new)
10
+ allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_return('012345')
11
+ allow(TTY::Prompt).to receive(:new).and_raise('unexpected interactive prompt')
12
+ end
13
+
14
+ it 'returns the YubiKey OATH digits without removing a leading zero or prompting' do
15
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(yubikey).and_return('012345')
16
+ expect(described_class.mfa(yubikey: yubikey)).to eq('012345')
17
+ end
18
+
19
+ [false, true, 'Example', [], {}, { account: nil }, { account: 123 }, { account: '' }, { account: ' ' }, { account: "Example\nOther" }].each do |invalid|
20
+ it "rejects invalid YubiKey options #{invalid.inspect} without a device call or prompt" do
21
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
22
+ expect { described_class.mfa(yubikey: invalid) }.to raise_error(ArgumentError, /yubikey|account/)
23
+ end
24
+ end
25
+
26
+ it 'preserves the exact account name and caller options' do
27
+ exact = { account: ' Example:tester@example.invalid ', confirm: true }.freeze
28
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(exact).and_return('00123456')
29
+ expect(described_class.mfa(yubikey: exact)).to eq('00123456')
30
+ end
31
+
32
+ it 'propagates a YubiKey failure without falling back to an interactive prompt' do
33
+ allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_raise(IOError, 'device unavailable')
34
+ expect { described_class.mfa(yubikey: yubikey) }.to raise_error(IOError, 'device unavailable')
35
+ end
36
+
37
+ it 'rejects an OTP slot instead of treating it as an OATH credential' do
38
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
39
+ expect { described_class.mfa(yubikey: yubikey.merge(slot: 2)) }.to raise_error(ArgumentError, /slot.*OATH/)
40
+ end
41
+
42
+ it 'propagates device cancellation instead of returning a value for a form' do
43
+ allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_raise(Interrupt)
44
+ expect { described_class.mfa(yubikey: yubikey) }.to raise_error(Interrupt)
45
+ end
46
+
47
+ [nil, false, 'true', 1].each do |confirm|
48
+ it "rejects confirm #{confirm.inspect} before a device call or prompt" do
49
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
50
+ expect { described_class.mfa(yubikey: yubikey.merge(confirm: confirm)) }.to raise_error(ArgumentError, /confirm: true.*HOTP.*authenticat/i)
51
+ end
52
+ end
53
+
54
+ it 'requires explicit consent during pre-login validation without modifying options' do
55
+ options = { yubikey: yubikey.except(:confirm).freeze }.freeze
56
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
57
+ expect { described_class.validate_mfa_options(options) }.to raise_error(ArgumentError, /confirm: true/)
58
+ expect(options[:yubikey]).not_to have_key(:confirm)
59
+ end
60
+
61
+ it 'does not accept string-keyed consent as the literal confirm option' do
62
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
63
+ expect { described_class.mfa(yubikey: yubikey.except(:confirm).merge('confirm' => true)) }.to raise_error(ArgumentError, /confirm: true/)
64
+ end
65
+
66
+ it 'keeps malformed-account and OTP-slot errors ahead of missing consent' do
67
+ expect { described_class.validate_mfa_options(yubikey: { account: '' }) }.to raise_error(ArgumentError, /account must/)
68
+ expect { described_class.validate_mfa_options(yubikey: { slot: 2 }) }.to raise_error(ArgumentError, /slot.*OATH/)
69
+ end
70
+
71
+ [{ mfa: '654321' }, { mfa_token: '654321' }].each do |conflict|
72
+ it "keeps the #{conflict.keys.first} conflict ahead of missing consent" do
73
+ expect { described_class.validate_mfa_options(conflict.merge(yubikey: yubikey.except(:confirm))) }.to raise_error(ArgumentError, /combined.*mfa.*mfa_token/)
74
+ end
75
+ end
76
+
77
+ it 'documents exact OATH selection, optional device controls, and fail-closed behavior' do
78
+ expect { described_class.help }.to output(/yubikey:.*exact.*account.*serial.*reader.*device_path.*connection.*timeout.*password.*secret_provider.*no OTP slot.*validate_mfa_options/m).to_stdout
79
+ expect { described_class.help }.to output(/Example yubikey: \{ account: 'EXACT_OATH_NAME', confirm: true.*HOTP.*authenticat/m).to_stdout
80
+ expect { described_class.help }.not_to output(/ykman/).to_stdout
81
+ end
82
+
83
+ it 'preserves the manual prompt when YubiKey is not selected' do
84
+ prompt = instance_double(TTY::Prompt)
85
+ allow(TTY::Prompt).to receive(:new).and_return(prompt)
86
+ expect(prompt).to receive(:ask).with('Test MFA: ').and_return(" 012345\n")
87
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
88
+ expect(described_class.mfa(prompt: 'Test MFA')).to eq('012345')
89
+ end
90
+ end
@@ -158,7 +158,7 @@ describe 'Native capability broker daemon' do
158
158
  status = File.read("/proc/#{pid}/status")
159
159
  expect(status[/^Uid:\s+(.+)/, 1].split.map(&:to_i)).to eq([Process.uid] * 4)
160
160
  expect(status[/^Gid:\s+(.+)/, 1].split.map(&:to_i)).to eq([Process.gid] * 4)
161
- expect(status[/^Groups:[^\S\n]*([^\n]*)/, 1].split.map(&:to_i)).to eq(Process.groups.sort)
161
+ expect(status[/^Groups:[^\S\n]*([^\n]*)/, 1].split.map(&:to_i).sort).to eq(Process.groups.sort)
162
162
  expect(status[/^NoNewPrivs:\s+(\d+)/, 1]).to eq('1')
163
163
  expect(status[/^CapEff:\s+(\h+)/, 1].to_i(16) & ~((1 << 12) | (1 << 13))).to eq(0)
164
164
  expect(status[/^CapPrm:\s+(\h+)/, 1].to_i(16) & ~((1 << 12) | (1 << 13))).to eq(0)
@@ -86,34 +86,23 @@ describe PWN::Plugins::GoogleWorkspace do
86
86
  Dir.mktmpdir('pwn-gw-oauth-') do |dir|
87
87
  env_path = File.join(dir, 'pwn.yaml')
88
88
  dec_path = "#{env_path}.decryptor"
89
- key = '0' * 32
90
- iv = '1' * 16
91
- File.write(env_path, "plugins:\n google_workspace:\n oauth:\n client_id: cid\n client_secret: sec\n")
92
- File.write(dec_path, YAML.dump(key: key, iv: iv))
93
- allow(PWN::Plugins::Vault).to receive(:decrypt)
94
- allow(PWN::Plugins::Vault).to receive(:encrypt)
89
+ stub_const('PWN::Plugins::GoogleWorkspace::PENDING_FILE', File.join(dir, 'pending.json'))
90
+ stub_const('PWN::Env', {
91
+ plugins: { google_workspace: { oauth: { client_id: 'cid', client_secret: 'sec' } } },
92
+ driver_opts: { pwn_env_path: env_path, pwn_dec_path: dec_path }
93
+ })
94
+ File.write(env_path, YAML.dump(PWN::Env))
95
+ PWN::Plugins::Vault.create(file: env_path, decryptor_file: dec_path)
96
+ credentials = YAML.safe_load_file(dec_path, symbolize_names: true)
95
97
  allow(RestClient).to receive(:post).and_return(
96
98
  instance_double(RestClient::Response, body: {
97
- access_token: 'live-bearer',
98
- refresh_token: 'live-refresh',
99
+ access_token: 'fixture-bearer',
100
+ refresh_token: 'fixture-refresh',
99
101
  expires_in: 3600,
100
102
  scope: 'https://www.googleapis.com/auth/gmail.modify'
101
103
  }.to_json)
102
104
  )
103
105
 
104
- PWN::Env[:plugins] ||= {}
105
- PWN::Env[:plugins][:google_workspace] ||= {}
106
- PWN::Env[:plugins][:google_workspace][:oauth] ||= {}
107
- slot = PWN::Env[:plugins][:google_workspace][:oauth]
108
- prev = slot.dup
109
- slot[:client_id] = 'cid'
110
- slot[:client_secret] = 'sec'
111
- slot[:bearer_token] = nil
112
- slot[:refresh_token] = nil
113
- PWN::Env[:driver_opts] ||= {}
114
- PWN::Env[:driver_opts][:pwn_env_path] = env_path
115
- PWN::Env[:driver_opts][:pwn_dec_path] = dec_path
116
-
117
106
  result = described_class.obtain_oauth_auth_url(
118
107
  client_id: 'cid',
119
108
  client_secret: 'sec',
@@ -122,20 +111,13 @@ describe PWN::Plugins::GoogleWorkspace do
122
111
  probe: ->(_redirect) { 'AUTHCODE' }
123
112
  )
124
113
 
125
- expect(result[:bearer_token]).to eq('live-bearer')
126
- expect(result[:refresh_token]).to eq('live-refresh')
127
- expect(slot[:bearer_token]).to eq('live-bearer')
128
- expect(slot[:refresh_token]).to eq('live-refresh')
129
- vault = YAML.load_file(env_path, symbolize_names: true)
130
- expect(vault.dig(:plugins, :google_workspace, :oauth, :bearer_token)).to eq('live-bearer')
131
- expect(vault.dig(:plugins, :google_workspace, :oauth, :refresh_token)).to eq('live-refresh')
132
- ensure
133
- if prev
134
- slot.clear
135
- slot.merge!(prev)
136
- end
137
- PWN::Env[:driver_opts].delete(:pwn_env_path) if PWN::Env[:driver_opts].is_a?(Hash)
138
- PWN::Env[:driver_opts].delete(:pwn_dec_path) if PWN::Env[:driver_opts].is_a?(Hash)
114
+ expect(result[:bearer_token]).to eq('fixture-bearer')
115
+ expect(result[:refresh_token]).to eq('fixture-refresh')
116
+ expect(PWN::Env.dig(:plugins, :google_workspace, :oauth)).to include(bearer_token: 'fixture-bearer', refresh_token: 'fixture-refresh')
117
+ vault = PWN::Plugins::Vault.dump({ file: env_path }.merge(credentials))
118
+ expect(vault.dig(:plugins, :google_workspace, :oauth)).to include(bearer_token: 'fixture-bearer', refresh_token: 'fixture-refresh')
119
+ expect(PWN::Plugins::Vault.file_encrypted?(file: env_path)).to be(true)
120
+ expect(File.stat(env_path).mode & 0o777).to eq(0o600)
139
121
  end
140
122
  end
141
123
 
@@ -15,6 +15,13 @@ describe PWN::Plugins::Log do
15
15
  end
16
16
 
17
17
  describe 'pwn-ai debug session' do
18
+ include_context 'pwn tmp sandbox'
19
+
20
+ before do
21
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
22
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
23
+ end
24
+
18
25
  after do
19
26
  PWN::Plugins::Log.stop_debug if PWN::Plugins::Log.respond_to?(:stop_debug)
20
27
  end