pwn 0.5.784 → 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 (242) 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/documentation/AI-Integration.md +7 -5
  8. data/documentation/Agent-Tool-Registry.md +2 -2
  9. data/documentation/Backup-Retention.md +70 -15
  10. data/documentation/Configuration.md +10 -6
  11. data/documentation/Contributing.md +1 -1
  12. data/documentation/Cron.md +2 -2
  13. data/documentation/Diagrams.md +2 -2
  14. data/documentation/General-PWN-Usage.md +4 -3
  15. data/documentation/Home.md +6 -5
  16. data/documentation/How-PWN-Works.md +6 -5
  17. data/documentation/Installation.md +12 -9
  18. data/documentation/Mission-Cancellation.md +134 -0
  19. data/documentation/Operability.md +17 -0
  20. data/documentation/Persistence.md +7 -5
  21. data/documentation/Plugin-Degradation.md +12 -0
  22. data/documentation/Plugins.md +3 -2
  23. data/documentation/Runtime-Source-Protection.md +162 -0
  24. data/documentation/Transparent-Browser.md +11 -0
  25. data/documentation/Troubleshooting.md +3 -2
  26. data/documentation/What-is-PWN.md +2 -2
  27. data/documentation/YubiKey-Vaults.md +183 -0
  28. data/documentation/YubiKey.md +339 -0
  29. data/documentation/diagrams/cron-scheduling.svg +130 -130
  30. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  31. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  32. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  33. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  34. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  35. data/documentation/pwn-ai-Agent.md +10 -4
  36. data/etc/default_cron/session_cleanup.rb +43 -27
  37. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  38. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  39. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  40. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  41. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  42. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  43. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  44. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  45. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  46. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  47. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  48. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  49. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  57. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  58. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  59. data/lib/pwn/ai/agent/learning.rb +14 -18
  60. data/lib/pwn/ai/agent/loop.rb +17 -1
  61. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  62. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  63. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  64. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  65. data/lib/pwn/ai/agent/reward.rb +5 -3
  66. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  67. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  68. data/lib/pwn/ai/agent/swarm.rb +4 -0
  69. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  70. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  71. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  72. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  73. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  74. data/lib/pwn/ai/agent.rb +1 -0
  75. data/lib/pwn/ai/anthropic.rb +8 -33
  76. data/lib/pwn/ai/cli.rb +34 -5
  77. data/lib/pwn/ai/gemini.rb +1 -0
  78. data/lib/pwn/ai/grok.rb +39 -28
  79. data/lib/pwn/ai/ollama.rb +4 -2
  80. data/lib/pwn/ai/open_ai.rb +11 -32
  81. data/lib/pwn/ai/open_web_ui.rb +62 -4
  82. data/lib/pwn/ai/router.rb +8 -1
  83. data/lib/pwn/ai/server_tools.rb +231 -0
  84. data/lib/pwn/ai.rb +1 -0
  85. data/lib/pwn/backups.rb +347 -0
  86. data/lib/pwn/config.rb +70 -39
  87. data/lib/pwn/migrate.rb +85 -280
  88. data/lib/pwn/module_skills.rb +7 -0
  89. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  90. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  91. data/lib/pwn/plugins/basic_auth.rb +2 -1
  92. data/lib/pwn/plugins/google_workspace.rb +4 -21
  93. data/lib/pwn/plugins/jobs.rb +1 -1
  94. data/lib/pwn/plugins/log.rb +3 -1
  95. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  96. data/lib/pwn/plugins/recon.rb +4 -1
  97. data/lib/pwn/plugins/repl/ai.rb +293 -26
  98. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  99. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  100. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  101. data/lib/pwn/plugins/repl/vault.rb +4 -15
  102. data/lib/pwn/plugins/repl.rb +1 -216
  103. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  104. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  105. data/lib/pwn/plugins/vault.rb +412 -135
  106. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  107. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  108. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  109. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  110. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  111. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  112. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  113. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  114. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  115. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  116. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  117. data/lib/pwn/plugins/yubikey.rb +844 -0
  118. data/lib/pwn/plugins.rb +1 -0
  119. data/lib/pwn/redaction.rb +69 -12
  120. data/lib/pwn/session_trace.rb +4 -0
  121. data/lib/pwn/setup.rb +114 -24
  122. data/lib/pwn/version.rb +1 -1
  123. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  124. data/lib/pwn/www/bug_crowd.rb +16 -5
  125. data/lib/pwn/www/coinbase_pro.rb +16 -5
  126. data/lib/pwn/www/github.rb +30 -14
  127. data/lib/pwn/www/paypal.rb +15 -4
  128. data/lib/pwn/www/synack.rb +16 -5
  129. data/lib/pwn/www/twitter.rb +17 -6
  130. data/lib/pwn.rb +1 -0
  131. data/spec/documentation/installation_md_spec.rb +1 -1
  132. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  133. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  134. data/spec/integration/controller_spill_spec.rb +48 -0
  135. data/spec/integration/harness_process_spec.rb +97 -0
  136. data/spec/integration/mission_cancel_spec.rb +79 -0
  137. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  138. data/spec/integration/readme_security_spec.rb +47 -0
  139. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  140. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  141. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  142. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  143. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  144. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  145. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  146. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  147. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  148. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  149. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  150. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  151. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  152. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  153. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  154. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  155. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  156. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  157. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  158. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  159. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  160. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  161. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  162. data/spec/lib/pwn/backups_spec.rb +114 -0
  163. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  164. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  165. data/spec/lib/pwn/migrate_spec.rb +29 -81
  166. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  167. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  168. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  169. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  170. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  171. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  172. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  173. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  174. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  175. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  176. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  177. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  178. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  179. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  180. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  181. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  182. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  183. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  184. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  185. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  186. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  187. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  188. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  189. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  190. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  191. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  192. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  193. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  194. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  195. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  196. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  197. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  198. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  199. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  200. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  201. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  202. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  203. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  204. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  205. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  206. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  207. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  208. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  209. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  210. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  211. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  212. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  213. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  214. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  215. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  216. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  217. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  218. data/spec/spec_helper.rb +15 -0
  219. data/spec/support/harness/cli_faults.rb +13 -9
  220. data/spec/support/harness/controller_spill.rb +26 -0
  221. data/spec/support/harness/controller_state.rb +54 -0
  222. data/spec/support/harness/evaluate.rb +12 -1
  223. data/spec/support/harness/experiments.rb +1 -1
  224. data/spec/support/harness/fake_provider.rb +22 -6
  225. data/spec/support/harness/fault_preload.rb +3 -1
  226. data/spec/support/harness/mission_console.rb +54 -0
  227. data/spec/support/harness/mission_curses_pty.rb +41 -0
  228. data/spec/support/harness/mission_dispatch.rb +30 -0
  229. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  230. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  231. data/spec/support/harness/mission_legacy.rb +25 -0
  232. data/spec/support/harness/mission_normal_exit.rb +34 -0
  233. data/spec/support/harness/mission_swarm.rb +23 -0
  234. data/spec/support/harness/mission_unsupported.rb +29 -0
  235. data/spec/support/harness/package_acceptance.rb +33 -5
  236. data/spec/support/harness/session_acceptance.rb +4 -4
  237. data/spec/support/harness/swarm_acceptance.rb +1 -1
  238. data/spec/support/harness/swarm_control.rb +44 -0
  239. data/spec/support/mission_namespace.rb +14 -0
  240. data/spec/support/mission_pipe_queue.rb +33 -0
  241. data/third_party/pwn_rdoc.jsonl +589 -19
  242. metadata +129 -13
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Config, 'YubiKey configuration redaction' do
6
+ it 'redacts string-keyed hardware settings inside arrays without hiding public selectors' do
7
+ config = { devices: [{ 'PIN' => '1', 'management-key' => 'synthetic-key', 'oath-secret' => 'synthetic-oath', 'serial' => '12345678', 'slot' => 2 }] }
8
+ result = described_class.redact_sensitive_artifacts(config: config)
9
+ masked = result[:devices].first
10
+ expect(masked.values_at('PIN', 'management-key', 'oath-secret').uniq).to eq(['>>> REDACTED >>> USE `pwn-vault` FOR ADMINISTRATION <<< REDACTED <<<'])
11
+ expect(masked).to include('serial' => '12345678', 'slot' => 2)
12
+ expect(config.dig(:devices, 0, 'PIN')).to eq('1')
13
+ end
14
+
15
+ it 'uses the same constant mask for short PINs and hardware secrets while retaining public selectors' do
16
+ fields = %i[pin new_pin admin_pin puk new_puk management_key new_management_key management_password access_code new_access_code oath_secret oath_password unlock_code unlock_password lock_code new_lock_code reset_code scp scp_key scp_keys scp03_key scp11_key static_password hmac_secret]
17
+ values = fields.to_h { |field| [field, '123456'] }
18
+ values[:pin] = '1'
19
+ values[:puk] = '87654321'
20
+ config = { plugins: { yubikey: values.merge(serial: '12345678', slot: 2, reader: 'fixture-reader') } }
21
+ before = Marshal.dump(config)
22
+ redacted = described_class.redact_sensitive_artifacts(config: config).dig(:plugins, :yubikey)
23
+ mask = described_class.redact_sensitive_artifacts(config: { password: 'different-value' })[:password]
24
+ fields.each { |field| expect(redacted[field]).to eq(mask), "#{field} must use the constant mask" }
25
+ expect(redacted).to include(serial: '12345678', slot: 2, reader: 'fixture-reader')
26
+ expect(Marshal.dump(config)).to eq(before)
27
+ end
28
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Config, 'hardware vault configuration' do
7
+ around do |example|
8
+ Dir.mktmpdir('config-yubikey-') do |root|
9
+ @root = root
10
+ example.run
11
+ end
12
+ end
13
+
14
+ before do
15
+ allow(Dir).to receive(:home).and_return(@root)
16
+ stub_const('PWN::Env', {})
17
+ stub_const('PWN::EnvRedacted', {})
18
+ stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
19
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
20
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
21
+ end
22
+ allow(described_class).to receive(:install_default_skills)
23
+ allow(described_class).to receive(:load_skills)
24
+ allow(PWN::Memory).to receive(:load)
25
+ allow(PWN::Cron).to receive(:install_defaults)
26
+ allow(PWN::Migrate).to receive(:needed?).and_return(false)
27
+ @path = File.join(@root, 'pwn.yaml')
28
+ end
29
+
30
+ it 'rejects an invalid hardware selector rather than falling back to a software sidecar' do
31
+ expect { described_class.default_env(pwn_env_path: @path, yubikey: false) }.to raise_error(ArgumentError)
32
+ expect(File).not_to exist(@path)
33
+ expect(File).not_to exist("#{@path}.decryptor")
34
+ end
35
+
36
+ it 'creates the default hardware configuration path when called directly' do
37
+ described_class.default_env(yubikey: { slot: 2 })
38
+ path = File.join(@root, '.pwn', 'pwn.yaml')
39
+ expect(PWN::Plugins::Vault.dump(file: path)).to eq(described_class.env_template)
40
+ expect(File).not_to exist("#{path}.decryptor")
41
+ end
42
+
43
+ it 'preserves multiline template values containing leading colons during creation' do
44
+ template = Marshal.load(Marshal.dump(described_class.env_template))
45
+ text = "before\n:leading-colon\nafter\n"
46
+ template[:ai][:ollama][:system_role_content] = text
47
+ allow(described_class).to receive(:env_template).and_return(template)
48
+ described_class.default_env(pwn_env_path: @path, yubikey: {})
49
+ expect(PWN::Plugins::Vault.dump(file: @path).dig(:ai, :ollama, :system_role_content)).to eq(text)
50
+ end
51
+
52
+ it 'retains software creation by default and honors the explicitly selected legacy decryptor' do
53
+ sidecar = File.join(@root, 'custom-sidecar')
54
+ expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
55
+ described_class.refresh_env(pwn_env_path: @path, pwn_dec_path: sidecar)
56
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(false)
57
+ credentials = YAML.safe_load_file(sidecar, symbolize_names: true)
58
+ expect(PWN::Plugins::Vault.dump(credentials.merge(file: @path))).to eq(described_class.env_template)
59
+ described_class.refresh_env(pwn_env_path: @path, pwn_dec_path: sidecar)
60
+ expect(PWN::Env.dig(:driver_opts, :pwn_dec_path)).to eq(sidecar)
61
+ expect(File).not_to exist("#{@path}.decryptor")
62
+ end
63
+
64
+ it 'creates a hardware vault only on explicit opt-in, without staging plaintext or making a sidecar' do
65
+ described_class.refresh_env(pwn_env_path: @path, yubikey: { slot: 2 })
66
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
67
+ expect(PWN::Plugins::Vault.dump(file: @path)).to eq(described_class.env_template)
68
+ expect(File).not_to exist("#{@path}.decryptor")
69
+ expect(File.stat(@path).mode & 0o777).to eq(0o600)
70
+ expect(PWN::Env).not_to have_key(:yubikey)
71
+ end
72
+
73
+ it 'leaves no plaintext configuration behind when opted-in hardware creation fails' do
74
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response).and_raise(IOError, 'synthetic device unavailable')
75
+ expect { described_class.default_env(pwn_env_path: @path, yubikey: { slot: 2 }) }.to raise_error(IOError)
76
+ expect(File).not_to exist(@path)
77
+ expect(File).not_to exist("#{@path}.decryptor")
78
+ end
79
+
80
+ it 'refreshes a self-describing hardware vault without a decryptor or software-key prompt' do
81
+ original = { ai: { active: 'ollama', ollama: { model: 'fixture-model' } }, retained: 'fixture-value' }
82
+ bytes = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(original), yubikey: { slot: 2, serial: '12345678' })
83
+ File.binwrite(@path, bytes)
84
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
85
+ described_class.refresh_env(pwn_env_path: @path)
86
+ expect(PWN::Env.dig(:ai, :ollama, :model)).to eq('fixture-model')
87
+ expect(PWN::Env[:retained]).to eq('fixture-value')
88
+ expect(PWN::Env.dig(:driver_opts, :pwn_env_path)).to eq(@path)
89
+ expect(File.binread(@path)).to eq(bytes)
90
+ expect(File).not_to exist("#{@path}.decryptor")
91
+ end
92
+ end
@@ -30,7 +30,7 @@ describe PWN::Migrate, 'exact migration snapshot scope' do
30
30
  File.write(File.join(@tmp, 'cron', 'worker.log'), 'new diagnostic')
31
31
  File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
32
32
  result = described_class.prune_backups(dry_run: false, expire_history: true)
33
- expect(result[:deleted]).to eq(%w[20260101_000000 20260102_000000])
33
+ expect(result).to include(deleted: [], keep: 7, scope: 'global')
34
34
  expect(File.read(File.join(@tmp, 'cron', 'worker.log'))).to eq('new diagnostic')
35
35
  end
36
36
  end
@@ -76,57 +76,17 @@ describe PWN::Migrate do
76
76
  allow(PWN::Config).to receive(:pwn_skills_path).and_return(File.join(@tmp, 'skills'))
77
77
  end
78
78
 
79
- it 'expires unique old history only after current recovery coverage is established' do
80
- File.write(File.join(@tmp, 'memory.json'), '{"current":true}')
81
- 4.times do |i|
82
- path = File.join(PWN::Migrate::BACKUP_ROOT, "2026010#{i + 1}_000000")
83
- FileUtils.mkdir_p(path)
84
- File.write(File.join(path, 'memory.json'), i == 3 ? '{"current":true}' : "{\"old\":#{i}}")
85
- end
86
- result = PWN::Migrate.prune_backups(dry_run: false, expire_history: true)
87
- expect(result[:deleted]).to eq(['20260101_000000'])
88
- expect(result[:retained].length).to eq(3)
89
- expect(File).to exist(File.join(PWN::Migrate::BACKUP_ROOT, '.retention-manifest.json.gz'))
90
- expect(File.read(File.join(@tmp, 'memory.json'))).to eq('{"current":true}')
91
- end
92
-
93
- context 'bounded historical expiry safeguards' do
94
- before do
95
- File.write(File.join(@tmp, 'memory.json'), '{}')
96
- 5.times do |i|
97
- path = File.join(PWN::Migrate::BACKUP_ROOT, "2026010#{i + 1}_000000")
98
- FileUtils.mkdir_p(path)
99
- File.write(File.join(path, 'memory.json'), '{}')
100
- end
101
- end
102
-
103
- it 'pins actual referenced evidence, not quoted inventory prose' do
104
- File.write(File.join(@tmp, 'learning.jsonl'), "#{JSON.generate(evidence_path: "#{PWN::Migrate::BACKUP_ROOT}/20260101_000000/memory.json", prose: "old path #{PWN::Migrate::BACKUP_ROOT}/20260102_000000/memory.json")}\n")
105
- FileUtils.cp(File.join(@tmp, 'learning.jsonl'), File.join(PWN::Migrate::BACKUP_ROOT, '20260105_000000', 'learning.jsonl'))
106
- result = PWN::Migrate.prune_backups(dry_run: false, expire_history: true)
107
- expect(result[:deleted]).to eq(['20260102_000000'])
108
- expect(result[:pins]).to eq(['20260101_000000'])
109
- end
110
-
111
- it 'refuses expiry without current coverage or matching credential recovery' do
112
- File.write(File.join(@tmp, 'pwn.yaml'), 'encrypted-current-fixture')
113
- result = PWN::Migrate.prune_backups(dry_run: false, expire_history: true)
114
- expect(result[:incomplete]).to be true
115
- expect(result[:deleted]).to be_empty
116
- File.write(File.join(PWN::Migrate::BACKUP_ROOT, '20260105_000000', 'pwn.yaml'), 'encrypted-old-fixture')
117
- expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true)[:error]).to match(/encrypted configuration/)
118
- end
119
-
120
- it 'refuses symlinks and held producer locks and exhausted admission' do
121
- File.symlink(File.join(@tmp, 'memory.json'), File.join(PWN::Migrate::BACKUP_ROOT, '20260101_000000', 'unsafe'))
122
- expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true)[:deleted]).to be_empty
123
- File.unlink(File.join(PWN::Migrate::BACKUP_ROOT, '20260101_000000', 'unsafe'))
124
- File.open(File.join(PWN::Migrate::BACKUP_ROOT, '.retention.lock'), 'w') do |lock|
125
- lock.flock(File::LOCK_EX)
126
- expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true)[:error]).to eq('Backup maintenance active')
127
- end
128
- expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true, admission: ->(_bytes) { false })[:deleted]).to be_empty
129
- end
79
+ it 'upgrades the existing three-folder policy to seven without pruning legacy data' do
80
+ FileUtils.mkdir_p(described_class::BACKUP_ROOT, mode: 0o700)
81
+ policy = File.join(described_class::BACKUP_ROOT, '.retention-policy.json')
82
+ File.write(policy, JSON.generate(version: 1, keep: 3, pins: [], expire_history: true))
83
+ File.write(described_class::SCHEMA_FILE, JSON.generate(schema: 11))
84
+ result = described_class.run(backup: false, io: io)
85
+ expect(result[:applied_migrations]).to eq([12])
86
+ expect(JSON.parse(File.read(policy))).to include('keep' => 7, 'scope' => 'global')
87
+ bytes = File.binread(policy)
88
+ expect(described_class.run(backup: false, io: io)[:applied_migrations]).to eq([])
89
+ expect(File.binread(policy)).to eq(bytes)
130
90
  end
131
91
 
132
92
  it '.needed? / .status on a fresh (empty) root' do
@@ -166,7 +126,7 @@ describe PWN::Migrate do
166
126
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 2))
167
127
  expect(described_class.needed?).to be(true)
168
128
  result = described_class.run(fix: false, backup: false, io: io)
169
- expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10, 11])
129
+ expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
170
130
  expected = Marshal.load(Marshal.dump(original))
171
131
  expected['custom']['model'] = nil
172
132
  expect(YAML.safe_load_file(path)).to eq(expected)
@@ -275,7 +235,7 @@ describe PWN::Migrate do
275
235
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 3))
276
236
  expect(described_class.needed?).to be(true)
277
237
  result = described_class.run(fix: true, backup: false, io: io)
278
- expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10, 11])
238
+ expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10, 11, 12])
279
239
  creds = YAML.safe_load_file(dec, symbolize_names: true)
280
240
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
281
241
  expect(user[:ai][:active]).to eq('grok')
@@ -301,7 +261,7 @@ describe PWN::Migrate do
301
261
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
302
262
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 4))
303
263
  result = described_class.run(fix: true, backup: false, io: io)
304
- expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10, 11])
264
+ expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10, 11, 12])
305
265
  creds = YAML.safe_load_file(dec, symbolize_names: true)
306
266
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
307
267
  expect(user.dig(:ai, :agent, :skill_review)).to eq('off')
@@ -326,7 +286,7 @@ describe PWN::Migrate do
326
286
  File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
327
287
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
328
288
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 5))
329
- expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10, 11])
289
+ expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10, 11, 12])
330
290
  creds = YAML.safe_load_file(dec, symbolize_names: true)
331
291
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
332
292
  theme = user.dig(:ai, :tui, :theme)
@@ -373,7 +333,7 @@ describe PWN::Migrate do
373
333
  File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
374
334
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
375
335
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 8))
376
- expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10, 11])
336
+ expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10, 11, 12])
377
337
  creds = YAML.safe_load_file(dec, symbolize_names: true)
378
338
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
379
339
  long_fast = user.dig(:plugins, :meshtastic, :channel, :LongFast)
@@ -411,20 +371,18 @@ describe PWN::Migrate, 'backup housekeeping' do
411
371
  expect(File.binread(File.join(@tmp, '.schema'))).to eq(before)
412
372
  end
413
373
 
414
- it 'retains three recovery points and unique history but prunes verified duplicate snapshots' do
374
+ it 'leaves legacy snapshots alone instead of running a competing pruner' do
415
375
  root = described_class::BACKUP_ROOT
416
- 5.times do |i|
376
+ 8.times do |i|
417
377
  dir = File.join(root, "2026010#{i + 1}_000000")
418
378
  FileUtils.mkdir_p(dir)
419
- File.write(File.join(dir, 'learning.jsonl'), i.zero? ? 'unique evidence' : 'same evidence')
379
+ File.write(File.join(dir, 'learning.jsonl'), 'unique evidence')
420
380
  end
421
- preview = described_class.prune_backups
422
- expect(preview[:eligible].length).to eq(1)
423
- expect(Dir.children(root).length).to eq(5)
424
- applied = described_class.prune_backups(dry_run: false)
425
- expect(applied[:deleted].length).to eq(1)
426
- expect(File.binread(File.join(root, '20260101_000000', 'learning.jsonl'))).to eq('unique evidence')
427
- expect(Dir.children(root).grep(/\A\d/).length).to eq(4)
381
+ result = described_class.prune_backups(dry_run: false, keep: 3, expire_history: true)
382
+ expect(result).to include(keep: 7, scope: 'global', deleted: [])
383
+ expect(Dir.children(root).length).to eq(8)
384
+ expect { described_class.send(:backup!) }.to raise_error(IOError)
385
+ expect(Dir.children(root).grep(/\A\d/).length).to eq(8)
428
386
  end
429
387
 
430
388
  it 'publishes a verified scoped snapshot and fails closed on a copy failure' do
@@ -441,21 +399,11 @@ describe PWN::Migrate, 'backup housekeeping' do
441
399
  expect { described_class.send(:backup!) }.to raise_error(IOError, 'copy failed')
442
400
  end
443
401
 
444
- it 'rejects symlinked backup trees and exhausted admission without deleting anything' do
402
+ it 'rejects a symlinked backup root without touching its target' do
445
403
  root = described_class::BACKUP_ROOT
446
- FileUtils.mkdir_p(root)
447
- File.symlink(@tmp, File.join(root, '20260101_000000'))
448
- expect(described_class.prune_backups(dry_run: false)[:incomplete]).to be(true)
449
- File.unlink(File.join(root, '20260101_000000'))
450
- 4.times do |i|
451
- dir = File.join(root, "2026010#{i + 1}_000000")
452
- FileUtils.mkdir_p(dir)
453
- File.write(File.join(dir, 'state'), 'same')
454
- end
455
- result = described_class.prune_backups(dry_run: false, admission: ->(_bytes) { false })
456
- expect(result[:incomplete]).to be(true)
457
- expect(result[:deleted]).to eq([])
458
- expect(Dir.children(root).grep(/\A\d/).length).to eq(4)
404
+ File.symlink(@tmp, root)
405
+ expect { described_class.send(:backup!) }.to raise_error(IOError, /Symlink/)
406
+ expect(File.symlink?(root)).to be(true)
459
407
  end
460
408
 
461
409
  it 'skips backups for fix mode when schema, files and vault have no drift' do
@@ -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'