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
data/lib/pwn/migrate.rb CHANGED
@@ -52,7 +52,7 @@ module PWN
52
52
  # Bump this whenever the shape of any file under ~/.pwn changes in a
53
53
  # way that requires a one-time transform. Add the transform as an
54
54
  # entry in MIGRATIONS keyed by the NEW schema number.
55
- SCHEMA_VERSION = 11
55
+ SCHEMA_VERSION = 12
56
56
 
57
57
  OK = "\e[32mok\e[0m"
58
58
  BAD = "\e[31mFAIL\e[0m"
@@ -84,7 +84,7 @@ module PWN
84
84
 
85
85
  enc = PWN::Plugins::Vault.file_encrypted?(file: p)
86
86
  # Shape (missing keys vs env_template) is checked separately in
87
- # .vault_drift because it requires the decryptor.
87
+ # .vault_drift because it requires access to the legacy key or YubiKey.
88
88
  { ok: enc, why: enc ? nil : 'not encrypted (run PWN::Plugins::Vault.create)' }
89
89
  }
90
90
  },
@@ -245,37 +245,41 @@ module PWN
245
245
  File.write(path, YAML.dump(raw))
246
246
  io.puts " · agents.yml added default model to #{patched} personas"
247
247
  },
248
- 4 => lambda { |_root, io|
248
+ 4 => lambda { |_root, io, vault_opts = {}|
249
249
  io.puts ' · pwn.yaml ai_sandbox, ai_router, model_routes task classes'
250
- PWN::Migrate.backfill_vault(io: io)
250
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
251
251
  },
252
- 5 => lambda { |_root, io|
252
+ 5 => lambda { |_root, io, vault_opts = {}|
253
253
  io.puts ' · pwn.yaml ai.agent.skill_review'
254
- PWN::Migrate.backfill_vault(io: io)
254
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
255
255
  },
256
- 6 => lambda { |_root, io|
256
+ 6 => lambda { |_root, io, vault_opts = {}|
257
257
  io.puts ' · pwn.yaml ai.tui.theme'
258
- PWN::Migrate.backfill_vault(io: io)
258
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
259
259
  },
260
- 7 => lambda { |_root, io|
260
+ 7 => lambda { |_root, io, vault_opts = {}|
261
261
  io.puts ' · pwn.yaml ai.tui.theme.title'
262
- PWN::Migrate.backfill_vault(io: io)
262
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
263
263
  },
264
264
  8 => lambda { |_root, io|
265
265
  io.puts ' · guarded session_cleanup default cron payload and job'
266
266
  PWN::Cron.install_defaults
267
267
  },
268
- 9 => lambda { |_root, io|
268
+ 9 => lambda { |_root, io, vault_opts = {}|
269
269
  io.puts ' · pwn.yaml plugins.meshtastic.channel short_name'
270
- PWN::Migrate.backfill_vault(io: io)
270
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
271
271
  },
272
- 10 => lambda { |_root, io|
272
+ 10 => lambda { |_root, io, vault_opts = {}|
273
273
  io.puts ' · pwn.yaml ai reasoning_effort for anthropic, gemini, ollama, openwebui'
274
- PWN::Migrate.backfill_vault(io: io)
274
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
275
275
  },
276
276
  11 => lambda { |_root, io|
277
277
  io.puts ' · versioned housekeeping policy and managed session_cleanup helper'
278
278
  PWN::Cron.install_defaults
279
+ },
280
+ 12 => lambda { |root, io|
281
+ io.puts ' · global seven-folder recovery policy; legacy relocation remains explicit'
282
+ PWN::Backups.configure(root: File.join(root, 'backup'))
279
283
  }
280
284
  }.freeze
281
285
 
@@ -309,14 +313,15 @@ module PWN
309
313
  end
310
314
 
311
315
  # Supported Method Parameters::
312
- # rows = PWN::Migrate.status
316
+ # rows = PWN::Migrate.status(pwn_env_path: 'optional - Selected configuration vault path')
313
317
  #
314
318
  # Per-file compatibility report — the machine-readable half of
315
319
  # `.check`. Never writes. Never raises (each verifier is rescued).
316
320
 
317
- public_class_method def self.status
321
+ public_class_method def self.status(opts = {})
322
+ selected_vault = opts[:pwn_env_path] || vault_paths[:file]
318
323
  files = STATE_FILES.map do |rel, meta|
319
- path = File.join(PWN_ROOT, rel)
324
+ path = meta[:kind] == :vault ? selected_vault : File.join(PWN_ROOT, rel)
320
325
  exists = meta[:kind] == :dir ? File.directory?(path) : File.file?(path)
321
326
  raw = exists && meta[:kind] != :dir ? safe_read(path: path) : nil
322
327
  v = verify_one(path: path, raw: raw, meta: meta)
@@ -385,18 +390,21 @@ module PWN
385
390
  # and decryptor available) 5. stamp .schema.
386
391
 
387
392
  public_class_method def self.run(opts = {})
393
+ return PWN::Backups.transaction(root: BACKUP_ROOT) { run(opts) } unless opts[:dry_run] || PWN::Backups.active?
394
+
388
395
  fix = opts[:fix] ? true : false
389
396
  dry_run = opts[:dry_run] ? true : false
390
397
  backup = opts.fetch(:backup, true)
391
398
  io = opts[:io] || $stdout
392
399
 
393
400
  FileUtils.mkdir_p(PWN_ROOT) unless dry_run
394
- before = status
401
+ before = status(pwn_env_path: opts[:pwn_env_path])
395
402
  io.puts "PWN::Migrate — pwn #{before[:pwn_version]} · ~/.pwn schema " \
396
403
  "#{before[:schema_installed]} → #{before[:schema_current]}" \
397
404
  "#{' (dry-run)' if dry_run}"
398
405
 
399
- drift = fix ? vault_drift(key: opts[:key], iv: opts[:iv]) : nil
406
+ vault_opts = { key: opts[:key], iv: opts[:iv], yubikey: opts[:yubikey], pwn_env_path: opts[:pwn_env_path], pwn_dec_path: opts[:pwn_dec_path] }
407
+ drift = fix ? vault_drift(vault_opts) : nil
400
408
  changes = before[:schema_installed] < SCHEMA_VERSION ||
401
409
  (fix && (!before[:incompatible].empty? || (drift && !drift[:missing].empty?)))
402
410
  bpath = nil
@@ -412,7 +420,9 @@ module PWN
412
420
  next unless m
413
421
 
414
422
  io.puts " migrate : v#{n - 1} → v#{n}"
415
- m.call(PWN_ROOT, io) unless dry_run
423
+ unless dry_run
424
+ m.arity == 2 ? m.call(PWN_ROOT, io) : m.call(PWN_ROOT, io, vault_opts)
425
+ end
416
426
  applied << n
417
427
  end
418
428
 
@@ -428,13 +438,13 @@ module PWN
428
438
 
429
439
  # pwn.yaml key backfill runs even when the vault verifier passed —
430
440
  # "encrypted" ≠ "has every key this release added".
431
- vd = backfill_vault(key: opts[:key], iv: opts[:iv], dry_run: dry_run, io: io)
441
+ vd = backfill_vault(vault_opts.merge(dry_run: dry_run, io: io))
432
442
  fixed << vd if vd && vd[:added].to_i.positive?
433
443
  end
434
444
 
435
445
  stamp! if !dry_run && changes
436
446
 
437
- after = status
447
+ after = status(pwn_env_path: opts[:pwn_env_path])
438
448
  io.puts
439
449
  io.puts " result : #{after[:incompatible].length} incompatible remaining " \
440
450
  "(was #{before[:incompatible].length}) · schema now #{after[:schema_installed]}"
@@ -452,16 +462,15 @@ module PWN
452
462
  #
453
463
  # Deep-diff the user's decrypted pwn.yaml against the current
454
464
  # PWN::Config.env_template. → { missing: [dotted.paths], user: {…} }
455
- # Returns nil when the decryptor is unavailable (never prompts).
465
+ # Returns nil when vault access is unavailable (never prompts).
456
466
 
457
467
  public_class_method def self.vault_drift(opts = {})
458
- key, iv = decryptor(opts)
459
- return nil unless key && iv
460
-
461
- yaml = File.join(PWN_ROOT, 'pwn.yaml')
468
+ paths = vault_paths(opts)
469
+ yaml = paths[:file]
462
470
  return nil unless File.file?(yaml) && PWN::Plugins::Vault.file_encrypted?(file: yaml)
463
471
 
464
- user = PWN::Plugins::Vault.dump(file: yaml, key: key, iv: iv)
472
+ access = PWN::Plugins::Vault.access_options(paths.merge(key: opts[:key], iv: opts[:iv], yubikey: opts[:yubikey]))
473
+ user = PWN::Plugins::Vault.dump(access.merge(file: yaml))
465
474
  tmpl = env_template
466
475
  { missing: missing_paths(tmpl: tmpl, user: user), user: user, template: tmpl }
467
476
  rescue StandardError
@@ -474,7 +483,7 @@ module PWN
474
483
  # )
475
484
  #
476
485
  # Decrypt → deep-merge template UNDER user (never overwrites) →
477
- # re-encrypt with the SAME key/iv. This is the fix for the #1
486
+ # re-encrypt with the SAME legacy key/iv or hardware selector. This fixes the
478
487
  # upgrade break: `NoMethodError: undefined method '[]' for nil` when
479
488
  # a release added `env[:ai][:new_engine][:…]` and the user's vault
480
489
  # predates it.
@@ -482,9 +491,9 @@ module PWN
482
491
  public_class_method def self.backfill_vault(opts = {})
483
492
  io = opts[:io] || $stdout
484
493
  dry_run = opts[:dry_run] ? true : false
485
- d = vault_drift(key: opts[:key], iv: opts[:iv])
494
+ d = vault_drift(opts)
486
495
  unless d
487
- io.puts ' vault : skipped (no decryptor available — run with --pwn-dec or set key:/iv:)'
496
+ io.puts ' vault : skipped (vault access unavailable — check YubiKey or legacy decryptor/key/IV)'
488
497
  return nil
489
498
  end
490
499
  if d[:missing].empty?
@@ -497,13 +506,10 @@ module PWN
497
506
  io.puts " … (+#{d[:missing].length - 8} more)" if d[:missing].length > 8
498
507
  return { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing], dry_run: true } if dry_run
499
508
 
500
- merged = deep_defaults(user: d[:user], tmpl: d[:template])
501
- yaml = File.join(PWN_ROOT, 'pwn.yaml')
502
- key, iv = decryptor(opts)
503
- # Same colon-stripping default_env uses so the file stays diff-clean.
504
- File.write(yaml, YAML.dump(merged).gsub(/^(\s*):/, '\1'))
505
- File.chmod(0o600, yaml)
506
- PWN::Plugins::Vault.encrypt(file: yaml, key: key, iv: iv)
509
+ access = vault_paths(opts).merge(key: opts[:key], iv: opts[:iv], yubikey: opts[:yubikey])
510
+ PWN::Plugins::Vault.update(access) do |user|
511
+ user.replace(deep_defaults(user: user, tmpl: d[:template]))
512
+ end
507
513
 
508
514
  { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing] }
509
515
  rescue StandardError => e
@@ -623,213 +629,18 @@ module PWN
623
629
  end
624
630
 
625
631
  # Supported Method Parameters::
626
- # PWN::Migrate.prune_backups(dry_run: true, keep: 3, limit: 10, admission: nil)
627
- # Default duplicate-only pruning is nondestructive to unique history.
628
- # Explicit expire_history policy bounds recovery generations instead.
632
+ # PWN::Migrate.prune_backups(dry_run: true)
633
+ # Retention now runs only at shared producer admission, never separately.
629
634
  public_class_method def self.prune_backups(opts = {})
630
- return expire_backup_generations(opts) if opts[:expire_history] || backup_retention_policy['expire_history']
631
-
632
- dry_run = opts.fetch(:dry_run, true)
633
- keep = [Integer(opts.fetch(:keep, 3)), 3].max
634
- limit = Integer(opts.fetch(:limit, 10))
635
- admission = opts[:admission]
636
- result = { eligible: [], deleted: [], retained: [], incomplete: false }
637
- return result unless File.directory?(BACKUP_ROOT)
638
- raise IOError, 'Unsafe backup root' unless File.realpath(BACKUP_ROOT) == BACKUP_ROOT && File.stat(BACKUP_ROOT).uid == Process.uid
639
-
640
- work = lambda do
641
- names = Dir.children(BACKUP_ROOT).grep(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/).sort
642
- retained = names.last(keep)
643
- manifests = retained.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
644
- names[0...-keep].to_a.last(limit).reverse_each do |name|
645
- path = File.join(BACKUP_ROOT, name)
646
- inventory = backup_inventory(path: path, admission: admission)
647
- match = manifests.find { |_other, rows| rows == inventory }
648
- unless match
649
- result[:retained] << name
650
- next
651
- end
652
- result[:eligible] << name
653
- next if dry_run
654
-
655
- canonical = File.join(BACKUP_ROOT, match.first)
656
- raise IOError, 'Backup changed before pruning' unless backup_inventory(path: canonical, admission: admission) == inventory && backup_inventory(path: path, admission: admission) == inventory
657
-
658
- FileUtils.remove_entry_secure(path)
659
- raise IOError, 'Backup pruning failed' if File.exist?(path)
660
-
661
- result[:deleted] << name
662
- end
663
- result
664
- end
665
- return work.call if dry_run
666
-
667
- File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
668
- raise IOError, 'Backup maintenance active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
669
-
670
- work.call
671
- end
672
- rescue IOError => e
673
- result[:incomplete] = true
674
- result[:error] = e.message
675
- result
676
- end
677
-
678
- # Explicit opt-in expires historical versions, not merely duplicate bytes.
679
- private_class_method def self.backup_retention_policy
680
- path = File.join(BACKUP_ROOT, '.retention-policy.json')
681
- return { 'keep' => 3, 'pins' => [], 'expire_history' => false } unless File.exist?(path) || File.symlink?(path)
682
- raise IOError, 'Unsafe retention policy' unless File.realpath(path) == path && File.stat(path).uid == Process.uid
683
-
684
- value = JSON.parse(File.binread(path))
685
- valid = value['version'] == 1 && [true, false].include?(value['expire_history']) && value['keep'].is_a?(Integer) && value['keep'] >= 3 && value['pins'].is_a?(Array)
686
- valid &&= value['pins'].all? { |name| name.is_a?(String) && name.match?(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/) }
687
- raise IOError, 'Invalid retention policy' unless valid
688
-
689
- value
635
+ { eligible: [], deleted: [], retained: [], incomplete: false,
636
+ dry_run: opts.fetch(:dry_run, true), keep: PWN::Backups::LIMIT,
637
+ scope: 'global', retention: 'shared_producer_admission' }
690
638
  end
691
639
 
692
640
  private_class_method def self.backup_input_names
693
641
  (STATE_FILES.keys.map { |rel| rel.delete_suffix('/') } + %w[.schema pwn.yaml.decryptor]).uniq.reject { |rel| %w[sessions swarm].include?(rel.split('/').first) }
694
642
  end
695
643
 
696
- # Conservative literal-path protection in current evidence/continuation stores.
697
- # Historical log/history/metrics copies are not operational dependencies.
698
- private_class_method def self.backup_reference_pins(opts = {})
699
- admission = opts[:admission]
700
- pins = []
701
- roots = %w[learning learning.jsonl memory.json mistakes.json policy.json policy_traj.jsonl preferences.jsonl engagements artifacts sessions swarm jobs runs open_goals open_goal.json curriculum finetune extrospection skill_review.jsonl maintenance]
702
- roots.each do |name|
703
- path = File.join(PWN_ROOT, name)
704
- next unless File.exist?(path) || File.symlink?(path)
705
-
706
- Find.find(path) do |file|
707
- stat = File.lstat(file)
708
- raise IOError, 'Unsafe current evidence entry' unless stat.file? || stat.directory?
709
- next unless stat.file? && file.match?(/\.jsonl?\z/)
710
- raise IOError, 'Backup work budget exhausted' if admission && !admission.call(stat.size + 1)
711
-
712
- documents = file.end_with?('.jsonl') ? File.foreach(file).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } : [JSON.parse(File.binread(file))]
713
- documents.select! { |row| row.is_a?(Hash) && (%w[prepared interrupted].include?(row['state']) || row['pinned'] == true) } if name == 'maintenance'
714
- until documents.empty?
715
- value = documents.pop
716
- case value
717
- when Hash then documents.concat(value.values)
718
- when Array then documents.concat(value)
719
- when String
720
- match = value.match(%r{\A(?:#{Regexp.escape(BACKUP_ROOT)}/|backup/)(\d{8}_\d{6}(?:_[a-f0-9]{8})?)(?:/|\z)})
721
- pins << match[1] if match
722
- end
723
- end
724
- end
725
- end
726
- pins.uniq
727
- end
728
-
729
- private_class_method def self.expire_backup_generations(opts = {})
730
- require 'zlib'
731
- result = { eligible: [], deleted: [], retained: [], incomplete: false, permanent_history_expiry: true }
732
- return result unless File.directory?(BACKUP_ROOT)
733
- raise IOError, 'Unsafe backup root' unless File.realpath(PWN_ROOT) == PWN_ROOT && File.realpath(BACKUP_ROOT) == BACKUP_ROOT && File.stat(BACKUP_ROOT).uid == Process.uid
734
-
735
- dry_run = opts.fetch(:dry_run, true)
736
- admission = opts[:admission]
737
- policy = backup_retention_policy
738
- keep = [Integer(opts.fetch(:keep, policy['keep'])), 3].max
739
- limit = Integer(opts.fetch(:limit, 10))
740
- raise ArgumentError, 'Invalid retention limit' unless limit.positive?
741
-
742
- operation = lambda do
743
- names = Dir.children(BACKUP_ROOT).grep(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/).sort
744
- raise IOError, 'Pinned backup missing' unless (policy['pins'] - names).empty?
745
-
746
- if (names - (names.last(keep) + policy['pins'])).empty?
747
- result[:retained] = names
748
- result[:pins] = policy['pins']
749
- result[:evidence_scan] = 'not_needed_no_expiry'
750
- return result
751
- end
752
-
753
- pins = (policy['pins'] + backup_reference_pins(admission: admission)).uniq
754
- raise IOError, 'Pinned backup missing' unless (pins - names).empty?
755
-
756
- retained = (names.last(keep) + pins).uniq.sort
757
- result[:retained] = retained
758
- result[:pins] = pins
759
- return result if (names - retained).empty?
760
- raise IOError, 'Insufficient recovery generations' if retained.length < keep
761
-
762
- current = backup_inventory(path: PWN_ROOT, names: backup_input_names, admission: admission)
763
- inventories = retained.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
764
- newest = inventories.fetch(names.last)
765
- raise IOError, 'Current migration input coverage missing' unless current.all? { |path, row| newest[path] && newest[path].first == row.first }
766
-
767
- %w[pwn.yaml pwn.yaml.decryptor].each do |name|
768
- raise IOError, 'Current encrypted configuration recovery missing' if current.key?(name) && newest[name] != current[name]
769
- end
770
- result[:coverage] = { current_paths: current.length, newest: names.last, legacy_completeness_certified: false, restore: 'copy migration-owned paths only; never overlay entire legacy trees or edit shared inodes' }
771
- candidates = (names - retained).first(limit)
772
- expired = candidates.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
773
- result[:eligible] = candidates
774
- return result if dry_run
775
-
776
- # Recheck everything before publishing the single private metadata ledger.
777
- raise IOError, 'Current migration input changed' unless current == backup_inventory(path: PWN_ROOT, names: backup_input_names, admission: admission)
778
- raise IOError, 'Backup references changed' unless pins == (policy['pins'] + backup_reference_pins(admission: admission)).uniq
779
-
780
- inventories.merge(expired).each do |name, rows|
781
- raise IOError, 'Backup changed before expiry' unless rows == backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)
782
- end
783
- ledger = File.join(BACKUP_ROOT, '.retention-manifest.json.gz')
784
- raise IOError, 'Unsafe retention manifest' if File.symlink?(ledger)
785
-
786
- record = { version: 1, state: 'prepared', policy: policy, coverage: result[:coverage], retained: inventories, expiring: expired, deleted: [] }
787
- verification_bytes = inventories.values.sum { |rows| rows.values.sum { |row| row.first == 'file' ? row[4] + 1 : 0 } }
788
- reserved_bytes = (JSON.generate(record).bytesize * 2) + verification_bytes
789
- raise IOError, 'Backup commit budget exhausted' if admission && !admission.call(reserved_bytes)
790
-
791
- publish_backup_retention(path: ledger, record: record)
792
- candidates.each do |name|
793
- FileUtils.remove_entry_secure(File.join(BACKUP_ROOT, name))
794
- raise IOError, 'Backup expiry failed' if File.exist?(File.join(BACKUP_ROOT, name))
795
-
796
- result[:deleted] << name
797
- record[:deleted] << name
798
- end
799
- inventories.each do |name, rows|
800
- raise IOError, 'Retained backup changed' unless rows == backup_inventory(path: File.join(BACKUP_ROOT, name))
801
- end
802
- record[:state] = 'committed'
803
- publish_backup_retention(path: ledger, record: record)
804
- result
805
- end
806
- return operation.call if dry_run
807
-
808
- File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
809
- raise IOError, 'Backup maintenance active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
810
-
811
- operation.call
812
- end
813
- rescue IOError => e
814
- result[:incomplete] = true
815
- result[:error] = e.message
816
- result
817
- end
818
-
819
- private_class_method def self.publish_backup_retention(opts = {})
820
- path = opts[:path]
821
- temporary = "#{path}.#{SecureRandom.hex(8)}"
822
- File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
823
- writer = Zlib::GzipWriter.new(file)
824
- writer.write(JSON.generate(opts[:record]))
825
- writer.finish
826
- file.flush
827
- file.fsync
828
- end
829
- File.rename(temporary, path)
830
- File.open(BACKUP_ROOT, File::RDONLY, &:fsync)
831
- end
832
-
833
644
  private_class_method def self.backup_inventory(opts = {})
834
645
  path = opts[:path]
835
646
  admission = opts[:admission]
@@ -859,38 +670,30 @@ module PWN
859
670
  end
860
671
 
861
672
  private_class_method def self.backup!
862
- FileUtils.mkdir_p(BACKUP_ROOT, mode: 0o700)
863
- raise IOError, 'Unsafe backup root' unless File.realpath(BACKUP_ROOT) == BACKUP_ROOT
864
-
865
- File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
866
- lock.flock(File::LOCK_EX)
867
- ts = "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}"
868
- dst = File.join(BACKUP_ROOT, ts)
869
- partial = File.join(BACKUP_ROOT, ".partial-#{ts}")
870
- Dir.mkdir(partial, 0o700)
871
- # Only state owned by the migration registry. Logs, caches, artifacts,
872
- # jobs and prior maintenance archives are not schema migration inputs.
673
+ PWN::Backups.transaction(root: BACKUP_ROOT) do
674
+ dst = PWN::Backups.directory(label: 'migration')
873
675
  names = backup_input_names
874
676
  inventory = backup_inventory(path: PWN_ROOT, names: names)
875
677
  names.each do |name|
876
678
  src = File.join(PWN_ROOT, name)
877
679
  next unless File.exist?(src)
878
680
 
879
- target = File.join(partial, name)
681
+ target = File.join(dst, name)
880
682
  FileUtils.mkdir_p(File.dirname(target), mode: 0o700)
881
683
  FileUtils.cp_r(src, target, preserve: true)
882
684
  end
883
685
  raise IOError, 'Migration input changed during backup' unless inventory == backup_inventory(path: PWN_ROOT, names: names)
884
- raise IOError, 'Backup verification failed' unless inventory == backup_inventory(path: partial, names: names)
686
+ raise IOError, 'Backup verification failed' unless inventory == backup_inventory(path: dst, names: names)
885
687
 
886
- manifest = JSON.generate(version: 1, complete: true, scope: names.sort, files: inventory)
887
- File.open(File.join(partial, '.snapshot-manifest.json'), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
888
- file.write(manifest)
688
+ inventory.each do |name, row|
689
+ File.open(File.join(dst, name), File::RDONLY, &:fsync) if row.first == 'file'
690
+ end
691
+ File.open(File.join(dst, '.snapshot-manifest.json'), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
692
+ file.write(JSON.generate(version: 1, complete: true, scope: names.sort, files: inventory))
889
693
  file.flush
890
694
  file.fsync
891
695
  end
892
- File.rename(partial, dst)
893
- File.open(BACKUP_ROOT, File::RDONLY, &:fsync)
696
+ File.open(dst, File::RDONLY, &:fsync)
894
697
  dst
895
698
  end
896
699
  end
@@ -908,18 +711,12 @@ module PWN
908
711
  SCHEMA_VERSION
909
712
  end
910
713
 
911
- private_class_method def self.decryptor(opts = {})
912
- key = opts[:key]
913
- iv = opts[:iv]
914
- return [key, iv] if key && iv
915
-
916
- dec = File.join(PWN_ROOT, 'pwn.yaml.decryptor')
917
- return [nil, nil] unless File.file?(dec)
918
-
919
- d = YAML.load_file(dec, symbolize_names: true)
920
- [d[:key], d[:iv]]
921
- rescue StandardError
922
- [nil, nil]
714
+ private_class_method def self.vault_paths(opts = {})
715
+ driver = defined?(PWN::Env) && PWN::Env.is_a?(Hash) ? PWN::Env[:driver_opts] : nil
716
+ driver = {} unless driver.is_a?(Hash)
717
+ file = opts[:pwn_env_path] || driver[:pwn_env_path] || File.join(PWN_ROOT, 'pwn.yaml')
718
+ decryptor_file = opts[:pwn_dec_path] || driver[:pwn_dec_path] || "#{file}.decryptor"
719
+ { file: file, decryptor_file: decryptor_file }
923
720
  end
924
721
 
925
722
  private_class_method def self.safe_read(opts = {})
@@ -1011,7 +808,9 @@ module PWN
1011
808
  #{self}.needed?
1012
809
 
1013
810
  # Run status and return its result
1014
- #{self}.status
811
+ #{self}.status(
812
+ pwn_env_path: 'optional - Vault path; defaults to driver options then ~/.pwn/pwn.yaml'
813
+ )
1015
814
 
1016
815
  # Human-readable ~/.pwn state doctor. Read-only. Called from
1017
816
  #{self}.check(
@@ -1025,29 +824,35 @@ module PWN
1025
824
  dry_run: 'optional - report what WOULD happen, write nothing (default false)',
1026
825
  key: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)',
1027
826
  iv: 'optional - vault iv (default: read ~/.pwn/pwn.yaml.decryptor)',
827
+ yubikey: 'optional - Hardware selector overrides: slot, serial, reader, device_path, connection, timeout',
828
+ pwn_env_path: 'optional - Vault path; defaults to driver options then ~/.pwn/pwn.yaml',
829
+ pwn_dec_path: 'optional - Legacy decryptor path; defaults to driver options then vault sidecar',
1028
830
  io: 'optional - IO to write to (default $stdout)'
1029
831
  )
1030
832
 
1031
833
  # Run vault drift and return its result
1032
834
  #{self}.vault_drift(
1033
- key: 'optional - vault key, iv: optional - vault iv'
835
+ key: 'optional - Legacy vault key override',
836
+ iv: 'optional - Legacy vault IV override',
837
+ yubikey: 'optional - Hardware selector overrides: slot, serial, reader, device_path, connection, timeout',
838
+ pwn_env_path: 'optional - Vault path; defaults to driver options then ~/.pwn/pwn.yaml',
839
+ pwn_dec_path: 'optional - Legacy decryptor path; hardware vaults need no sidecar'
1034
840
  )
1035
841
 
1036
842
  # Run backfill vault and return its result
1037
843
  #{self}.backfill_vault(
1038
- key: 'optional - optional, iv: optional, dry_run: false, io: $stdout',
1039
- io: 'optional - io value consumed by #backfill_vault',
1040
- dry_run: 'optional - dry run value consumed by #backfill_vault',
1041
- iv: 'optional - iv value consumed by #backfill_vault'
844
+ key: 'optional - Legacy vault key override',
845
+ iv: 'optional - Legacy vault IV override',
846
+ yubikey: 'optional - Hardware selector overrides: slot, serial, reader, device_path, connection, timeout',
847
+ pwn_env_path: 'optional - Vault path; defaults to driver options then ~/.pwn/pwn.yaml',
848
+ pwn_dec_path: 'optional - Legacy decryptor path; hardware vaults need no sidecar',
849
+ io: 'optional - Report output; defaults to $stdout',
850
+ dry_run: 'optional - Report missing keys without writing; defaults to false'
1042
851
  )
1043
852
 
1044
- # Prune duplicate trees, or explicitly expire historical generations.
853
+ # Compatibility report; shared producer admission owns retention.
1045
854
  #{self}.prune_backups(
1046
- expire_history: 'optional - permanently expire old unpinned history after current recovery coverage verification; default reads backup/.retention-policy.json',
1047
- dry_run: 'optional - preview without writes, default true',
1048
- keep: 'optional - recent recovery points to retain, minimum three',
1049
- limit: 'optional - maximum older snapshots examined per invocation, default ten',
1050
- admission: 'optional - callable byte/work budget admission guard'
855
+ dry_run: 'optional - preview without writes, default true'
1051
856
  )
1052
857
 
1053
858
  # Run env template and return its result
@@ -273,6 +273,13 @@ module PWN
273
273
  pending = []
274
274
  next
275
275
  end
276
+ if line =~ /^\s*class\s+([A-Z][\w:]*)/
277
+ ident = Regexp.last_match(1)
278
+ if snake(name: ident.split('::').last) == File.basename(src, '.rb')
279
+ const = ident.include?('::') ? ident : (stack + [ident]).join('::')
280
+ blurb = pending.join(' ').strip if blurb.empty?
281
+ end
282
+ end
276
283
  pending = []
277
284
  next if line =~ /^\s*private_class_method/
278
285