pwn 0.5.784 → 0.5.787

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/redaction'
5
+
6
+ RSpec.describe PWN::Redaction, 'hardware and MFA field redaction' do
7
+ it 'masks complete management-secret containers in serialized text' do
8
+ samples = [
9
+ 'scp_keys=["fixture-enc", "fixture-mac"] slot=2',
10
+ 'scp_keys: {enc: "fixture-enc", nested: ["fixture-mac", "fixture ] bracket"]} slot=2',
11
+ "scp_keys=[\n\"fixture-enc\",\n[\"fixture-mac\"]\n] slot=2"
12
+ ]
13
+ samples.each do |text|
14
+ clean = described_class.redact(value: text)
15
+ expect(clean).not_to include('fixture-enc', 'fixture-mac', 'fixture ] bracket')
16
+ expect(clean).to include('[REDACTED]', 'slot=2')
17
+ expect(described_class.redact(value: clean)).to eq(clean)
18
+ end
19
+ expect(described_class.redact(value: 'scp_keys=["fixture-enc"')).to eq('scp_keys=[REDACTED]')
20
+ expect(described_class.redact(value: 'oath_secret="fixture truncated secret')).to eq('oath_secret=[REDACTED]')
21
+ end
22
+
23
+ it 'does not let an existing mask exempt an adjacent ordinary secret from capture' do
24
+ text = 'password=fixture-prefix[REDACTED]fixture-suffix'
25
+ clean = described_class.capture(value: text, vault: false)
26
+ expect(clean[:redacted]).not_to include('fixture-prefix', 'fixture-suffix')
27
+ expect(clean[:refs]).not_to be_empty
28
+ end
29
+
30
+ it 'masks key-value text before ordinary token hashing or loot capture' do
31
+ samples = [
32
+ 'pin=123456', 'PUK: 654321', 'new_pin="123 456"', "'management_key' => 'fixture key'",
33
+ '--access-code=112233', 'oath_secret=fixture-oath', 'static_password=fixture-static',
34
+ 'scp_enc=fixture-enc', 'mfa_code=102938', '{"pin": "123456", "puk": 654321}'
35
+ ]
36
+ expect(PWN::Plugins::Vault).not_to receive(:store)
37
+ expect(PWN::Plugins::Vault).not_to receive(:fetch)
38
+ expect(PWN::Plugins::Vault).not_to receive(:expand)
39
+ samples.each do |text|
40
+ clean = described_class.redact(value: text)
41
+ expect(clean).to include('[REDACTED]'), text
42
+ expect(clean).not_to match(/123|654|112|102|fixture|\[REDACTED:[^\]]+/), text
43
+ expect(described_class.redact(value: clean)).to eq(clean)
44
+ expect(described_class.capture(value: text, entropy: true, yubikey: { slot: 2 })).to eq(redacted: clean, refs: [])
45
+ end
46
+ expect(described_class.redact(value: { api_key: 'fixture-api' })).to eq(api_key: described_class.token(kind: 'api_key', value: 'fixture-api'))
47
+ end
48
+
49
+ it 'uses a constant mask for structured PINs, PUKs, MFA codes and management secrets' do
50
+ fields = %w[pin PIN new_pin current-pin admin_pin user_pin fido2_pin piv_pin openpgp_pin puk new_puk access_code management_key management_password oath_secret static_password scp scp_key scp_keys scp_enc scp_mac scp_dek scp03_enc_key mfa_code mfa_token otp totp hotp otp_code totp_secret recovery_code verification_code]
51
+ fields.each do |field|
52
+ first = described_class.redact(value: { field => '123456' })
53
+ second = described_class.redact(value: { field => '654321' })
54
+ expect(first).to eq(field => '[REDACTED]'), field
55
+ expect(second).to eq(first), field
56
+ end
57
+ input = { nested: [{ pin: 1234, puk: nil, scp_keys: %w[fixture-one fixture-two] }], slot: 2, serial: '12345678', shipping: 'public' }
58
+ expect(described_class.redact(value: input)).to eq(nested: [{ pin: '[REDACTED]', puk: nil, scp_keys: '[REDACTED]' }], slot: 2, serial: '12345678', shipping: 'public')
59
+ expect(input[:nested].first[:pin]).to eq(1234)
60
+ end
61
+ end
@@ -32,7 +32,7 @@ RSpec.describe 'Bundled session cleanup' do
32
32
  File.write(policy, JSON.generate(config))
33
33
  File.write(PWN::Migrate::SCHEMA_FILE, JSON.generate(schema: 10))
34
34
  upgrade = PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)
35
- raise 'missing upgrade' unless upgrade[:applied_migrations] == [11]
35
+ raise 'missing upgrade' unless upgrade[:applied_migrations] == [11, 12]
36
36
  raise 'overwritten policy' unless JSON.parse(File.read(policy))['batch_size'] == 7
37
37
  raise 'non-idempotent upgrade' unless PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)[:applied_migrations].empty?
38
38
  raise 'wrong helper' unless File.read(File.join(PWN::Cron.cron_dir, 'session_cleanup.rb')).include?('session_cleanup v10')
@@ -60,8 +60,10 @@ RSpec.describe 'Bundled session cleanup' do
60
60
  begin
61
61
  job = PWN::Plugins::Jobs.start(command: command, session_id: sid, cwd: Dir.home, max_runtime: 20)
62
62
  raise 'job not running' unless PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
63
- maintenance = PWN::SessionCleanup.coordinate(dry_run: false)
64
- raise 'missing coordinator phases' unless maintenance[:phases].keys == %i[maintenance memory_consolidation learning_retention mistakes_retention policy_retention]
63
+ proc_root = File.join(Dir.home, 'fixture-proc')
64
+ FileUtils.mkdir_p(proc_root)
65
+ maintenance = PWN::SessionCleanup.coordinate(dry_run: false, proc_root: proc_root)
66
+ raise "missing coordinator phases: \#{maintenance.inspect}" unless maintenance[:phases].keys == %i[maintenance memory_consolidation learning_retention mistakes_retention policy_retention]
65
67
  raise 'active job session deleted' unless File.file?(File.join(PWN::Sessions.sessions_dir, sid + '.jsonl'))
66
68
  raise 'job dependency omitted' unless PWN::Sessions.dependency_index[:dependencies].fetch(sid).any? { |path| path.include?('/jobs/') }
67
69
  File.write(release, 'release')
@@ -79,7 +81,7 @@ RSpec.describe 'Bundled session cleanup' do
79
81
  RUBY
80
82
  output, error, ran = Open3.capture3(env, RbConfig.ruby, '-e', script, chdir: @tmp)
81
83
  expect(ran.success?).to be(true), "#{output}\n#{error}"
82
- expect(JSON.parse(output)).to include('installed' => true, 'schema' => 11)
84
+ expect(JSON.parse(output)).to include('installed' => true, 'schema' => 12)
83
85
  end
84
86
 
85
87
  it 'never seeds curriculum on fresh install, upgrade or repeated migration after deletion' do
@@ -187,7 +189,7 @@ RSpec.describe 'Bundled session cleanup' do
187
189
  original = PWN::Cron.create(name: 'session_cleanup', schedule: '12 9 * * *', ruby: 'custom guard', enabled: false)
188
190
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 7))
189
191
  result = PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)
190
- expect(result[:applied_migrations]).to eq([8, 9, 10, 11])
192
+ expect(result[:applied_migrations]).to eq([8, 9, 10, 11, 12])
191
193
  expect(PWN::Cron.list.fetch(original[:id])).to eq(original)
192
194
  expect(PWN::Cron.list.values.count { |job| job[:name] == 'session_cleanup' }).to eq(1)
193
195
  expect(File).to exist(File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'))
@@ -183,8 +183,8 @@ RSpec.describe PWN::SessionCleanup do
183
183
  Thread.current[:pwn_steering_input] = nil
184
184
  end
185
185
 
186
- it 'expires only committed verified disposable backups and retains prepared recovery evidence' do
187
- file = File.join(Dir.home, '.pwn', 'tmp', 'expired-backup')
186
+ it 'leaves centralized recovery expiry to shared admission rather than quarantine pruning' do
187
+ file = File.join(described_class.disposable_directory('tmp'), 'expired-backup')
188
188
  FileUtils.mkdir_p(File.dirname(file))
189
189
  File.write(file, 'disposable')
190
190
  described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
@@ -192,11 +192,10 @@ RSpec.describe PWN::SessionCleanup do
192
192
  backup = result[:records].first['backup']
193
193
  old = Time.now - (100 * 86_400)
194
194
  File.utime(old, old, result[:ledger])
195
- expect(described_class.prune_quarantine(dry_run: true)[:eligible]).to eq([backup])
196
- expect(File.file?(backup)).to be(true)
197
- expect(described_class.prune_quarantine(dry_run: false)[:deleted]).to eq([backup])
198
- expect(File.exist?(backup)).to be(false)
199
- expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('expired')
195
+ expect(described_class.prune_quarantine(dry_run: true)[:eligible]).to eq([])
196
+ expect(described_class.prune_quarantine(dry_run: false)[:deleted]).to eq([])
197
+ expect(File.read(backup)).to eq('disposable')
198
+ expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('committed')
200
199
  end
201
200
 
202
201
  it 'requires explicit continuation retirement and preserves an active association' do
@@ -235,7 +234,7 @@ RSpec.describe PWN::SessionCleanup do
235
234
  end
236
235
 
237
236
  it 'preserves failed writes and exposes interrupted prepared records instead of declaring completion' do
238
- file = File.join(Dir.home, '.pwn', 'tmp', 'failure')
237
+ file = File.join(described_class.disposable_directory('tmp'), 'failure')
239
238
  FileUtils.mkdir_p(File.dirname(file))
240
239
  File.binwrite(file, "binary\xFF".b)
241
240
  described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
@@ -252,7 +251,7 @@ RSpec.describe PWN::SessionCleanup do
252
251
  end
253
252
 
254
253
  it 'recovers exact disposable backups without overwriting changed destinations' do
255
- file = File.join(Dir.home, '.pwn', 'tmp', 'binary')
254
+ file = File.join(described_class.disposable_directory('tmp'), 'binary')
256
255
  FileUtils.mkdir_p(File.dirname(file))
257
256
  bytes = "café\xFF\x00".b
258
257
  File.binwrite(file, bytes)
@@ -321,8 +320,9 @@ RSpec.describe PWN::SessionCleanup do
321
320
 
322
321
  it 'reports all storage categories read-only and preserves unknown files' do
323
322
  root = File.join(Dir.home, '.pwn')
324
- FileUtils.mkdir_p(File.join(root, 'tmp'))
325
- unknown = File.join(root, 'tmp', 'unknown.bin')
323
+ runtime = described_class.disposable_directory('tmp')
324
+ FileUtils.mkdir_p(runtime)
325
+ unknown = File.join(runtime, 'unknown.bin')
326
326
  File.binwrite(unknown, "\xFF\x00".b)
327
327
  before = Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).sort
328
328
  status = described_class.status
@@ -90,7 +90,7 @@ RSpec.describe PWN::SessionCleanup do
90
90
  expect(JSON.parse(File.read(report[:transaction_ledger]))['state']).to eq('interrupted')
91
91
  end
92
92
 
93
- it 'indexes only committed legacy backup rows and compacts duplicates without unlinking the last copy' do
93
+ it 'keeps centralized copies out of the legacy compactor and retains learning rollback bytes' do
94
94
  described_class.install
95
95
  allow(described_class).to receive(:legacy_owners).and_return([])
96
96
  original = File.binread(path)
@@ -103,15 +103,15 @@ RSpec.describe PWN::SessionCleanup do
103
103
  PWN::Sessions.append(session_id: id, role: 'user', content: 'evidence')
104
104
  File.write(PWN::AI::Agent::Learning::LEARNING_FILE, JSON.generate(session_id: id, outcome: 'fixture'))
105
105
  migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false)
106
- learning_backup = migration[:ledger].sub('.json', '.backup.jsonl')
106
+ learning_backup = JSON.parse(File.read(migration[:ledger])).fetch('backup')
107
107
  inventory = described_class.legacy_backups.map { |row| row[:path] }
108
- expect(inventory).to include(*backups, learning_backup)
108
+ expect(inventory & (backups + [learning_backup])).to be_empty
109
109
  config = described_class.policy
110
110
  config['retention_days']['maintenance'] = 0
111
111
  File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
112
112
  result = described_class.prune_legacy_backups(dry_run: false)
113
- expect(result[:compacted] & backups).not_to be_empty
114
- expect(File.identical?(*backups)).to be(true)
113
+ expect(result[:compacted] & backups).to be_empty
114
+ expect(backups.map { |file| File.binread(file) }).to eq([original, original])
115
115
  expect(File.file?(learning_backup)).to be(true)
116
116
  record = JSON.parse(File.read(migration[:ledger])).merge('state' => 'prepared')
117
117
  File.write(migration[:ledger], JSON.generate(record))
@@ -125,8 +125,11 @@ RSpec.describe PWN::SessionCleanup do
125
125
  layout = File.join(directory, 'layout.json')
126
126
  File.write(layout, JSON.generate(version: 1, categories: { learning: ['learning_evidence'] }))
127
127
  described_class.install
128
- expect(JSON.parse(File.read(layout))).to include('version' => 2, 'categories' => described_class::STORAGE)
128
+ expect(JSON.parse(File.read(layout))).to include('version' => 3, 'categories' => described_class::STORAGE)
129
129
  expect(File.binread(File.join(directory, 'policy.json'))).to eq(config)
130
+ File.write(layout, JSON.generate(version: 2, categories: { 'tmp' => ['tmp'] }))
131
+ described_class.install
132
+ expect(JSON.parse(File.read(layout))).to include('version' => 3, 'categories' => described_class::STORAGE)
130
133
  File.write(layout, JSON.generate(version: 99, categories: {}))
131
134
  expect { described_class.install }.to raise_error(IOError, /inventory/)
132
135
  end
@@ -146,7 +149,7 @@ RSpec.describe PWN::SessionCleanup do
146
149
  lease&.close
147
150
  end
148
151
 
149
- it 'prunes redundant physical continuation backups while preserving rollback paths and unique evidence' do
152
+ it 'preserves centralized continuation recovery and does not run a competing compactor' do
150
153
  described_class.install
151
154
  root = File.join(Dir.home, '.pwn', 'swarm')
152
155
  swarm = '20200101_010203_abcdef'
@@ -165,25 +168,19 @@ RSpec.describe PWN::SessionCleanup do
165
168
  File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
166
169
  expect(File.identical?(*backups)).to be(false)
167
170
  report = described_class.prune_legacy_backups(dry_run: false)
168
- expect(report[:compacted].length).to eq(1)
169
- expect(File.identical?(*backups)).to be(true)
170
- expect(backups.map { |file| File.binread(file) }).to eq([original, original])
171
- expect(described_class.prune_legacy_backups(dry_run: false)[:compacted]).to be_empty
172
- expect(described_class.recovery_status(ledger: report[:ledger])[:state]).to eq('committed')
173
- # Model interruption after the atomic replacement but before commit readback.
174
- interrupted = JSON.parse(File.read(report[:ledger]))
175
- interrupted['state'] = 'prepared'
176
- interrupted['records'].each { |row| row['state'] = 'prepared' }
177
- File.write(report[:ledger], JSON.generate(interrupted))
178
- expect(described_class.recover(ledger: report[:ledger], action: 'resume', dry_run: false)[:state]).to eq('committed')
171
+ expect(report[:compacted]).to be_empty
172
+ expect(File.identical?(*backups)).to be(false)
179
173
  expect(backups.map { |file| File.binread(file) }).to eq([original, original])
174
+ ledger = File.join(File.dirname(backups.last), 'ledger.json')
175
+ expect(described_class.recover(ledger: ledger, action: 'rollback', dry_run: false)[:restored]).to eq([map])
176
+ expect(File.binread(map)).to eq(original)
180
177
  end
181
178
 
182
179
  it 'owns actual festival input through use and removes it without a global tmp sweep' do
183
180
  observed = nil
184
181
  allow(PWN::Plugins::Voice).to receive(:text_to_speech) do |opts|
185
182
  observed = opts[:text_path]
186
- expect(observed).to start_with("#{File.join(Dir.home, '.pwn', 'tmp')}/")
183
+ expect(observed).to start_with("#{described_class.disposable_directory('tmp')}/")
187
184
  expect(File.read(observed)).to eq('fixture speech')
188
185
  expect(described_class.clean_disposables(dry_run: false)[:deleted]).to be_empty
189
186
  end
@@ -224,11 +221,20 @@ RSpec.describe PWN::SessionCleanup do
224
221
  config['retention_days']['maintenance'] = 0
225
222
  File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
226
223
  2.times do |index|
227
- file = File.join(Dir.home, '.pwn', 'tmp', "expiry-#{index}")
224
+ file = File.join(described_class.disposable_directory('tmp'), "expiry-#{index}")
228
225
  File.write(file, "bytes-#{index}")
229
226
  described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
230
227
  end
231
228
  result = described_class.clean_disposables(dry_run: false)
229
+ # Exercise the retained legacy recovery reader with a genuine old layout.
230
+ old_directory = File.dirname(result[:ledger])
231
+ legacy_directory = File.join(Dir.home, '.pwn', 'maintenance', 'quarantine', 'legacy-expiry')
232
+ FileUtils.mkdir_p(File.dirname(legacy_directory))
233
+ FileUtils.mv(old_directory, legacy_directory)
234
+ result = JSON.parse(JSON.generate(result).gsub(old_directory, legacy_directory), symbolize_names: true)
235
+ data = JSON.parse(File.read(result[:ledger]).gsub(old_directory, legacy_directory))
236
+ File.write(result[:ledger], JSON.generate(data))
237
+ result[:records] = data.fetch('records')
232
238
  backups = result[:records].map { |row| row['backup'] }
233
239
  allow(File).to receive(:unlink).and_call_original
234
240
  allow(File).to receive(:unlink).with(backups.last).and_raise(Interrupt)
@@ -309,6 +315,14 @@ RSpec.describe PWN::SessionCleanup, 'disposable admission and scan progress' do
309
315
  described_class.cache(path: cache, bytes: 'q' * 1_048_576)
310
316
  described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
311
317
  removed = described_class.clean_disposables(dry_run: false)
318
+ old_directory = File.dirname(removed[:ledger])
319
+ legacy_directory = File.join(Dir.home, '.pwn', 'maintenance', 'quarantine', 'legacy-budget')
320
+ FileUtils.mkdir_p(File.dirname(legacy_directory))
321
+ FileUtils.mv(old_directory, legacy_directory)
322
+ removed = JSON.parse(JSON.generate(removed).gsub(old_directory, legacy_directory), symbolize_names: true)
323
+ data = JSON.parse(File.read(removed[:ledger]).gsub(old_directory, legacy_directory))
324
+ File.write(removed[:ledger], JSON.generate(data))
325
+ removed[:records] = data.fetch('records')
312
326
  backup = removed[:records].first.fetch('backup')
313
327
  config = described_class.policy.merge('work_bytes' => 8192)
314
328
  config['retention_days']['maintenance'] = 0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ffi'
5
+
6
+ RSpec.describe PWN::Setup, 'native library doctor' do
7
+ around do |example|
8
+ Dir.mktmpdir('native-library-doctor-') 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
+ allow(PWN::Migrate).to receive(:check).and_return(incompatible: [])
18
+ allow(described_class).to receive(:pkg_manager).and_return(key: :apt, install: 'install-package')
19
+ allow(described_class).to receive(:which).and_return('/fixture/bin')
20
+ allow(described_class).to receive(:gem_loadable?).and_return(true)
21
+ allow(described_class).to receive(:detect_os_attr).with(attr: :type, fallback: anything).and_return(:linux)
22
+ allow(described_class).to receive(:detect_os_attr).with(attr: :arch, fallback: anything).and_return('fixture')
23
+ allow(FFI::DynamicLibrary).to receive(:open).and_raise(LoadError, 'fixture library missing')
24
+ device = double('untouched native device')
25
+ stub_const('PWN::Plugins::YubiKey', device)
26
+ %i[devices info command challenge_response oath_code].each { |method| expect(device).not_to receive(method) }
27
+ end
28
+
29
+ it 'reports missing optional libraries but gates only hardware and full profiles on them' do
30
+ %i[core hardware full].each do |profile|
31
+ io = StringIO.new
32
+ report = described_class.check(profile: profile, io: io)
33
+ expect(report[:ok]).to eq(profile == :core)
34
+ expect(report[:native_libraries_missing]).to eq(%w[hidapi pcsc])
35
+ expect(report[:native_gems_missing]).to eq([])
36
+ expect(report[:toolchain_missing]).to eq([])
37
+ expect(io.string).to include('Native libraries', 'hidapi', 'pcsc', 'no device discovery')
38
+ end
39
+ end
40
+
41
+ it 'loads only library handles and accepts the alternative HIDAPI libusb backend' do
42
+ flags = FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_LOCAL
43
+ allow(FFI::DynamicLibrary).to receive(:open).with('libhidapi-libusb.so.0', flags).and_return(double('HID library'))
44
+ allow(FFI::DynamicLibrary).to receive(:open).with('libpcsclite.so.1', flags).and_return(double('PCSC library'))
45
+ report = described_class.check(profile: :hardware, io: StringIO.new)
46
+ expect(report[:ok]).to be(true)
47
+ expect(report[:native_libraries_missing]).to eq([])
48
+ expect(FFI::DynamicLibrary).to have_received(:open).with('libhidapi-hidraw.so.0', flags)
49
+ end
50
+
51
+ it 'recognizes the built-in macOS PCSC framework and Homebrew HIDAPI path' do
52
+ allow(described_class).to receive(:detect_os_attr).with(attr: :type, fallback: anything).and_return(:osx)
53
+ flags = FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_LOCAL
54
+ allow(FFI::DynamicLibrary).to receive(:open).with('/System/Library/Frameworks/PCSC.framework/PCSC', flags).and_return(double('system PCSC framework'))
55
+ allow(FFI::DynamicLibrary).to receive(:open).with('/opt/homebrew/lib/libhidapi.dylib', flags).and_return(double('HID library'))
56
+ report = described_class.check(profile: :hardware, io: StringIO.new)
57
+ expect(report[:ok]).to be(true)
58
+ expect(report[:native_libraries_missing]).to eq([])
59
+ end
60
+
61
+ it 'lists optional native libraries in profile descriptions' do
62
+ io = StringIO.new
63
+ described_class.list_profiles(io: io)
64
+ expect(io.string).to include('libs: hidapi pcsc')
65
+ end
66
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'stringio'
6
+
7
+ RSpec.describe PWN::Setup, 'hardware vault doctor' do
8
+ around do |example|
9
+ Dir.mktmpdir('setup-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
+ @pwn_root = File.join(@root, '.pwn')
18
+ FileUtils.mkdir_p(@pwn_root)
19
+ stub_const('PWN::Env', {})
20
+ stub_const('PWN::Migrate::PWN_ROOT', @pwn_root)
21
+ stub_const('PWN::Migrate::SCHEMA_FILE', File.join(@pwn_root, '.schema'))
22
+ stub_const('PWN::Setup::NATIVE_GEMS', {})
23
+ stub_const('PWN::Setup::TOOLCHAIN', {})
24
+ stub_const('PWN::Setup::NATIVE_LIBRARIES', {})
25
+ allow(described_class).to receive(:pkg_manager).and_return(key: :fixture)
26
+ allow(described_class).to receive(:detect_os_attr).and_return('fixture')
27
+ stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
28
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
29
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
30
+ end
31
+ @path = File.join(@pwn_root, 'pwn.yaml')
32
+ File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(ai: { active: 'ollama' }), yubikey: { slot: 2 }))
33
+ @io = StringIO.new
34
+ end
35
+
36
+ it 'uses Config driver paths consistently in setup and the migration state verifier' do
37
+ custom = File.join(@root, 'custom-vault')
38
+ File.rename(@path, custom)
39
+ PWN::Env[:driver_opts] = { pwn_env_path: custom, pwn_dec_path: File.join(@root, 'custom-sidecar') }
40
+ expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
41
+ report = described_class.check(io: @io)
42
+ expect(report[:ok]).to be(true)
43
+ expect(report.dig(:state, :files).find { |row| row[:rel] == 'pwn.yaml' }).to include(ok: true, path: custom)
44
+ expect(@io.string).to include('YubiKey', 'no software decryptor required')
45
+ expect(File).not_to exist(@path)
46
+ expect(File).not_to exist(PWN::Env.dig(:driver_opts, :pwn_dec_path))
47
+ end
48
+
49
+ it 'recognizes hardware vaults without a missing-decryptor warning or a device operation' do
50
+ bytes = File.binread(@path)
51
+ expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
52
+ expect(PWN::Plugins::Vault).not_to receive(:create)
53
+ report = described_class.check(io: @io)
54
+ expect(report[:ok]).to be(true)
55
+ expect(report.dig(:state, :incompatible)).to be_empty
56
+ expect(@io.string).to include('YubiKey', 'no software decryptor required')
57
+ expect(@io.string).not_to include('decryptor MISSING')
58
+ expect(File.binread(@path)).to eq(bytes)
59
+ expect(Dir.children(@pwn_root)).to eq(['pwn.yaml'])
60
+ end
61
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Setup, 'optional native YubiKey libraries' do
6
+ before do
7
+ allow(described_class).to receive(:check).and_return(ok: true)
8
+ allow(described_class).to receive(:gem_loadable?).and_return(true)
9
+ expect(described_class).not_to receive(:system)
10
+ end
11
+
12
+ it 'replaces executable provisioning with optional HIDAPI and PC/SC libraries' do
13
+ expect(described_class::TOOLCHAIN).not_to have_key('ykman')
14
+ expect(described_class::PROFILES.fetch(:hardware).fetch(:bins)).not_to include('ykman')
15
+ expect(described_class::PROFILES.fetch(:full).fetch(:bins)).not_to include('ykman')
16
+ expect(described_class::PROFILES.fetch(:hardware).fetch(:libs)).to eq(%w[hidapi pcsc])
17
+ expect(described_class::PROFILES.fetch(:full).fetch(:libs)).to eq(described_class::NATIVE_LIBRARIES.keys)
18
+ expect(Array(described_class::PROFILES.fetch(:core)[:libs])).to be_empty
19
+ end
20
+
21
+ { apt: %w[libhidapi-hidraw0 libpcsclite1 pcscd libccid],
22
+ dnf: %w[hidapi pcsc-lite-libs pcsc-lite pcsc-lite-ccid],
23
+ pacman: %w[hidapi pcsclite ccid], brew: %w[hidapi], port: %w[hidapi] }.each do |manager, libraries|
24
+ it "plans native host packages for #{manager} without a Python or CLI backend" do
25
+ allow(described_class).to receive(:pkg_manager).and_return(key: manager, install: 'install-package')
26
+ result = described_class.deps(profile: :hardware, dry_run: true, io: StringIO.new, distro: :fixture, version: 'test')
27
+ commands = result.fetch(:ran).map { |row| row.fetch(:cmd) }
28
+ libraries.each { |package| expect(commands).to include("install-package #{package}") }
29
+ expect(commands.join('\n')).not_to match(/ykman|yubikey-manager|python|\bpip(?:[23x])?\b/)
30
+ expect(result.fetch(:ran)).to all(include(dry_run: true))
31
+ expect(commands.join('\n')).not_to match(/pcsc|ccid/) if %i[brew port].include?(manager)
32
+ end
33
+ end
34
+
35
+ it 'does not install hardware packages in the core profile' do
36
+ allow(described_class).to receive(:pkg_manager).and_return(key: :apt, install: 'install-package')
37
+ result = described_class.deps(profile: :core, dry_run: true, io: StringIO.new, distro: :fixture, version: 'test')
38
+ expect(result.fetch(:ran).map { |row| row.fetch(:cmd) }.join('\n')).not_to match(/hidapi|pcsclite|pcscd|ccid|ykman/)
39
+ end
40
+ end