pwn 0.5.783 → 0.5.785

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
data/lib/pwn/migrate.rb CHANGED
@@ -4,6 +4,9 @@ require 'json'
4
4
  require 'yaml'
5
5
  require 'fileutils'
6
6
  require 'time'
7
+ require 'digest'
8
+ require 'find'
9
+ require 'securerandom'
7
10
 
8
11
  module PWN
9
12
  # PWN::Migrate — the ~/.pwn state **doctor** and **auto-migrator**.
@@ -49,7 +52,7 @@ module PWN
49
52
  # Bump this whenever the shape of any file under ~/.pwn changes in a
50
53
  # way that requires a one-time transform. Add the transform as an
51
54
  # entry in MIGRATIONS keyed by the NEW schema number.
52
- SCHEMA_VERSION = 10
55
+ SCHEMA_VERSION = 12
53
56
 
54
57
  OK = "\e[32mok\e[0m"
55
58
  BAD = "\e[31mFAIL\e[0m"
@@ -81,7 +84,7 @@ module PWN
81
84
 
82
85
  enc = PWN::Plugins::Vault.file_encrypted?(file: p)
83
86
  # Shape (missing keys vs env_template) is checked separately in
84
- # .vault_drift because it requires the decryptor.
87
+ # .vault_drift because it requires access to the legacy key or YubiKey.
85
88
  { ok: enc, why: enc ? nil : 'not encrypted (run PWN::Plugins::Vault.create)' }
86
89
  }
87
90
  },
@@ -242,33 +245,41 @@ module PWN
242
245
  File.write(path, YAML.dump(raw))
243
246
  io.puts " · agents.yml added default model to #{patched} personas"
244
247
  },
245
- 4 => lambda { |_root, io|
248
+ 4 => lambda { |_root, io, vault_opts = {}|
246
249
  io.puts ' · pwn.yaml ai_sandbox, ai_router, model_routes task classes'
247
- PWN::Migrate.backfill_vault(io: io)
250
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
248
251
  },
249
- 5 => lambda { |_root, io|
252
+ 5 => lambda { |_root, io, vault_opts = {}|
250
253
  io.puts ' · pwn.yaml ai.agent.skill_review'
251
- PWN::Migrate.backfill_vault(io: io)
254
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
252
255
  },
253
- 6 => lambda { |_root, io|
256
+ 6 => lambda { |_root, io, vault_opts = {}|
254
257
  io.puts ' · pwn.yaml ai.tui.theme'
255
- PWN::Migrate.backfill_vault(io: io)
258
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
256
259
  },
257
- 7 => lambda { |_root, io|
260
+ 7 => lambda { |_root, io, vault_opts = {}|
258
261
  io.puts ' · pwn.yaml ai.tui.theme.title'
259
- PWN::Migrate.backfill_vault(io: io)
262
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
260
263
  },
261
264
  8 => lambda { |_root, io|
262
265
  io.puts ' · guarded session_cleanup default cron payload and job'
263
266
  PWN::Cron.install_defaults
264
267
  },
265
- 9 => lambda { |_root, io|
268
+ 9 => lambda { |_root, io, vault_opts = {}|
266
269
  io.puts ' · pwn.yaml plugins.meshtastic.channel short_name'
267
- PWN::Migrate.backfill_vault(io: io)
270
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
268
271
  },
269
- 10 => lambda { |_root, io|
272
+ 10 => lambda { |_root, io, vault_opts = {}|
270
273
  io.puts ' · pwn.yaml ai reasoning_effort for anthropic, gemini, ollama, openwebui'
271
- PWN::Migrate.backfill_vault(io: io)
274
+ PWN::Migrate.backfill_vault(vault_opts.merge(io: io))
275
+ },
276
+ 11 => lambda { |_root, io|
277
+ io.puts ' · versioned housekeeping policy and managed session_cleanup helper'
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'))
272
283
  }
273
284
  }.freeze
274
285
 
@@ -302,14 +313,15 @@ module PWN
302
313
  end
303
314
 
304
315
  # Supported Method Parameters::
305
- # rows = PWN::Migrate.status
316
+ # rows = PWN::Migrate.status(pwn_env_path: 'optional - Selected configuration vault path')
306
317
  #
307
318
  # Per-file compatibility report — the machine-readable half of
308
319
  # `.check`. Never writes. Never raises (each verifier is rescued).
309
320
 
310
- public_class_method def self.status
321
+ public_class_method def self.status(opts = {})
322
+ selected_vault = opts[:pwn_env_path] || vault_paths[:file]
311
323
  files = STATE_FILES.map do |rel, meta|
312
- path = File.join(PWN_ROOT, rel)
324
+ path = meta[:kind] == :vault ? selected_vault : File.join(PWN_ROOT, rel)
313
325
  exists = meta[:kind] == :dir ? File.directory?(path) : File.file?(path)
314
326
  raw = exists && meta[:kind] != :dir ? safe_read(path: path) : nil
315
327
  v = verify_one(path: path, raw: raw, meta: meta)
@@ -378,19 +390,25 @@ module PWN
378
390
  # and decryptor available) 5. stamp .schema.
379
391
 
380
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
+
381
395
  fix = opts[:fix] ? true : false
382
396
  dry_run = opts[:dry_run] ? true : false
383
397
  backup = opts.fetch(:backup, true)
384
398
  io = opts[:io] || $stdout
385
399
 
386
- FileUtils.mkdir_p(PWN_ROOT)
387
- before = status
400
+ FileUtils.mkdir_p(PWN_ROOT) unless dry_run
401
+ before = status(pwn_env_path: opts[:pwn_env_path])
388
402
  io.puts "PWN::Migrate — pwn #{before[:pwn_version]} · ~/.pwn schema " \
389
403
  "#{before[:schema_installed]} → #{before[:schema_current]}" \
390
404
  "#{' (dry-run)' if dry_run}"
391
405
 
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
408
+ changes = before[:schema_installed] < SCHEMA_VERSION ||
409
+ (fix && (!before[:incompatible].empty? || (drift && !drift[:missing].empty?)))
392
410
  bpath = nil
393
- if backup && !dry_run && File.directory?(PWN_ROOT)
411
+ if backup && changes && !dry_run && File.directory?(PWN_ROOT)
394
412
  bpath = backup!
395
413
  io.puts " backup : #{bpath}"
396
414
  end
@@ -402,7 +420,9 @@ module PWN
402
420
  next unless m
403
421
 
404
422
  io.puts " migrate : v#{n - 1} → v#{n}"
405
- 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
406
426
  applied << n
407
427
  end
408
428
 
@@ -418,13 +438,13 @@ module PWN
418
438
 
419
439
  # pwn.yaml key backfill runs even when the vault verifier passed —
420
440
  # "encrypted" ≠ "has every key this release added".
421
- 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))
422
442
  fixed << vd if vd && vd[:added].to_i.positive?
423
443
  end
424
444
 
425
- stamp! unless dry_run
445
+ stamp! if !dry_run && changes
426
446
 
427
- after = status
447
+ after = status(pwn_env_path: opts[:pwn_env_path])
428
448
  io.puts
429
449
  io.puts " result : #{after[:incompatible].length} incompatible remaining " \
430
450
  "(was #{before[:incompatible].length}) · schema now #{after[:schema_installed]}"
@@ -442,16 +462,15 @@ module PWN
442
462
  #
443
463
  # Deep-diff the user's decrypted pwn.yaml against the current
444
464
  # PWN::Config.env_template. → { missing: [dotted.paths], user: {…} }
445
- # Returns nil when the decryptor is unavailable (never prompts).
465
+ # Returns nil when vault access is unavailable (never prompts).
446
466
 
447
467
  public_class_method def self.vault_drift(opts = {})
448
- key, iv = decryptor(opts)
449
- return nil unless key && iv
450
-
451
- yaml = File.join(PWN_ROOT, 'pwn.yaml')
468
+ paths = vault_paths(opts)
469
+ yaml = paths[:file]
452
470
  return nil unless File.file?(yaml) && PWN::Plugins::Vault.file_encrypted?(file: yaml)
453
471
 
454
- 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))
455
474
  tmpl = env_template
456
475
  { missing: missing_paths(tmpl: tmpl, user: user), user: user, template: tmpl }
457
476
  rescue StandardError
@@ -464,7 +483,7 @@ module PWN
464
483
  # )
465
484
  #
466
485
  # Decrypt → deep-merge template UNDER user (never overwrites) →
467
- # 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
468
487
  # upgrade break: `NoMethodError: undefined method '[]' for nil` when
469
488
  # a release added `env[:ai][:new_engine][:…]` and the user's vault
470
489
  # predates it.
@@ -472,9 +491,9 @@ module PWN
472
491
  public_class_method def self.backfill_vault(opts = {})
473
492
  io = opts[:io] || $stdout
474
493
  dry_run = opts[:dry_run] ? true : false
475
- d = vault_drift(key: opts[:key], iv: opts[:iv])
494
+ d = vault_drift(opts)
476
495
  unless d
477
- 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)'
478
497
  return nil
479
498
  end
480
499
  if d[:missing].empty?
@@ -487,13 +506,10 @@ module PWN
487
506
  io.puts " … (+#{d[:missing].length - 8} more)" if d[:missing].length > 8
488
507
  return { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing], dry_run: true } if dry_run
489
508
 
490
- merged = deep_defaults(user: d[:user], tmpl: d[:template])
491
- yaml = File.join(PWN_ROOT, 'pwn.yaml')
492
- key, iv = decryptor(opts)
493
- # Same colon-stripping default_env uses so the file stays diff-clean.
494
- File.write(yaml, YAML.dump(merged).gsub(/^(\s*):/, '\1'))
495
- File.chmod(0o600, yaml)
496
- 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
497
513
 
498
514
  { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing] }
499
515
  rescue StandardError => e
@@ -612,19 +628,74 @@ module PWN
612
628
  "dropped #{bad} bad line(s), kept #{good.length}"
613
629
  end
614
630
 
631
+ # Supported Method Parameters::
632
+ # PWN::Migrate.prune_backups(dry_run: true)
633
+ # Retention now runs only at shared producer admission, never separately.
634
+ public_class_method def self.prune_backups(opts = {})
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' }
638
+ end
639
+
640
+ private_class_method def self.backup_input_names
641
+ (STATE_FILES.keys.map { |rel| rel.delete_suffix('/') } + %w[.schema pwn.yaml.decryptor]).uniq.reject { |rel| %w[sessions swarm].include?(rel.split('/').first) }
642
+ end
643
+
644
+ private_class_method def self.backup_inventory(opts = {})
645
+ path = opts[:path]
646
+ admission = opts[:admission]
647
+ names = opts[:names]
648
+ raise IOError, 'Unsafe backup directory' unless File.realpath(path) == path && File.directory?(path)
649
+
650
+ rows = {}
651
+ Find.find(path) do |file|
652
+ next if file == path
653
+
654
+ rel = file.delete_prefix("#{path}/")
655
+ Find.prune if names && names.none? { |name| rel == name || rel.start_with?("#{name}/") || name.start_with?("#{rel}/") }
656
+ stat = File.lstat(file)
657
+ raise IOError, 'Unsafe backup entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
658
+ next if names && names.none? { |name| rel == name || rel.start_with?("#{name}/") }
659
+ raise IOError, 'Backup work budget exhausted' if admission && !admission.call(stat.file? ? stat.size + 1 : 0)
660
+
661
+ row = [stat.ftype, stat.mode, stat.uid, stat.gid]
662
+ if stat.file?
663
+ row += [stat.size, Digest::SHA256.file(file).hexdigest]
664
+ after = File.lstat(file)
665
+ raise IOError, 'Backup changed while hashing' unless [stat.ino, stat.size, stat.mtime, stat.ctime] == [after.ino, after.size, after.mtime, after.ctime]
666
+ end
667
+ rows[rel] = row
668
+ end
669
+ rows
670
+ end
671
+
615
672
  private_class_method def self.backup!
616
- ts = Time.now.utc.strftime('%Y%m%d_%H%M%S')
617
- dst = File.join(BACKUP_ROOT, ts)
618
- FileUtils.mkdir_p(dst)
619
- Dir.children(PWN_ROOT).each do |c|
620
- next if %w[backup quarantine sessions swarm].include?(c) # large / regenerable
673
+ PWN::Backups.transaction(root: BACKUP_ROOT) do
674
+ dst = PWN::Backups.directory(label: 'migration')
675
+ names = backup_input_names
676
+ inventory = backup_inventory(path: PWN_ROOT, names: names)
677
+ names.each do |name|
678
+ src = File.join(PWN_ROOT, name)
679
+ next unless File.exist?(src)
680
+
681
+ target = File.join(dst, name)
682
+ FileUtils.mkdir_p(File.dirname(target), mode: 0o700)
683
+ FileUtils.cp_r(src, target, preserve: true)
684
+ end
685
+ raise IOError, 'Migration input changed during backup' unless inventory == backup_inventory(path: PWN_ROOT, names: names)
686
+ raise IOError, 'Backup verification failed' unless inventory == backup_inventory(path: dst, names: names)
621
687
 
622
- src = File.join(PWN_ROOT, c)
623
- FileUtils.cp_r(src, dst)
624
- rescue StandardError
625
- next
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))
693
+ file.flush
694
+ file.fsync
695
+ end
696
+ File.open(dst, File::RDONLY, &:fsync)
697
+ dst
626
698
  end
627
- dst
628
699
  end
629
700
 
630
701
  private_class_method def self.stamp!
@@ -640,18 +711,12 @@ module PWN
640
711
  SCHEMA_VERSION
641
712
  end
642
713
 
643
- private_class_method def self.decryptor(opts = {})
644
- key = opts[:key]
645
- iv = opts[:iv]
646
- return [key, iv] if key && iv
647
-
648
- dec = File.join(PWN_ROOT, 'pwn.yaml.decryptor')
649
- return [nil, nil] unless File.file?(dec)
650
-
651
- d = YAML.load_file(dec, symbolize_names: true)
652
- [d[:key], d[:iv]]
653
- rescue StandardError
654
- [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 }
655
720
  end
656
721
 
657
722
  private_class_method def self.safe_read(opts = {})
@@ -743,7 +808,9 @@ module PWN
743
808
  #{self}.needed?
744
809
 
745
810
  # Run status and return its result
746
- #{self}.status
811
+ #{self}.status(
812
+ pwn_env_path: 'optional - Vault path; defaults to driver options then ~/.pwn/pwn.yaml'
813
+ )
747
814
 
748
815
  # Human-readable ~/.pwn state doctor. Read-only. Called from
749
816
  #{self}.check(
@@ -757,20 +824,35 @@ module PWN
757
824
  dry_run: 'optional - report what WOULD happen, write nothing (default false)',
758
825
  key: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)',
759
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',
760
830
  io: 'optional - IO to write to (default $stdout)'
761
831
  )
762
832
 
763
833
  # Run vault drift and return its result
764
834
  #{self}.vault_drift(
765
- 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'
766
840
  )
767
841
 
768
842
  # Run backfill vault and return its result
769
843
  #{self}.backfill_vault(
770
- key: 'optional - optional, iv: optional, dry_run: false, io: $stdout',
771
- io: 'optional - io value consumed by #backfill_vault',
772
- dry_run: 'optional - dry run value consumed by #backfill_vault',
773
- 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'
851
+ )
852
+
853
+ # Compatibility report; shared producer admission owns retention.
854
+ #{self}.prune_backups(
855
+ dry_run: 'optional - preview without writes, default true'
774
856
  )
775
857
 
776
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