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,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'digest'
6
+ require 'securerandom'
7
+ require 'find'
8
+
9
+ module PWN
10
+ # All first-party recovery copies share one serialized seven-generation store.
11
+ # Evidence artifacts are not recovery copies and must not enter this store.
12
+ module Backups
13
+ LIMIT = 7
14
+ GENERATION = /\A\d{8}_\d{6}_[a-f0-9]{8}\z/
15
+
16
+ # Supported Method Parameters::
17
+ # PWN::Backups.active?
18
+ public_class_method def self.active?
19
+ owner = Thread.current[:pwn_backup_transaction]
20
+ !owner.nil? && owner[:pid] == Process.pid && owner[:thread] == Thread.current
21
+ end
22
+
23
+ # Supported Method Parameters::
24
+ # PWN::Backups.transaction(root: nil) { ... }
25
+ public_class_method def self.transaction(opts = {})
26
+ owner = Thread.current[:pwn_backup_transaction]
27
+ override = Thread.current[:pwn_backup_root]
28
+ real_home = Thread.current[:pwn_backup_real_home]
29
+ override = nil if real_home && Dir.home != real_home
30
+ root = File.expand_path(opts[:root] || (owner[:root] if active?) || override || File.join(Dir.home, '.pwn', 'backup'))
31
+ return yield if owner && owner[:pid] == Process.pid && owner[:thread] == Thread.current && owner[:root] == root
32
+ raise IOError, 'Nested backup roots are not supported' if active?
33
+
34
+ learning = PWN::AI::Agent::Learning.send(:learning_lock)
35
+ safe_directory(path: root)
36
+ File.open(File.join(root, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
37
+ raise IOError, 'Unsafe backup lock' unless lock.stat.uid == Process.uid && lock.stat.nlink == 1
38
+
39
+ lock.flock(File::LOCK_EX)
40
+ context = { pid: Process.pid, thread: Thread.current, root: root }
41
+ Thread.current[:pwn_backup_transaction] = context
42
+ begin
43
+ result = yield
44
+ failed = result.is_a?(Hash) && (result[:error] || result[:ok] == false || result[:promoted] == false || result[:incomplete] || %w[prepared interrupted blocked].include?(result[:state].to_s))
45
+ write_state(context: context, state: 'complete') if context[:directory] && !failed
46
+ result
47
+ ensure
48
+ # Exceptions leave a prepared recovery generation, never silently expire it.
49
+ Thread.current[:pwn_backup_transaction] = owner
50
+ end
51
+ end
52
+ ensure
53
+ PWN::AI::Agent::Learning.send(:learning_unlock, lock: learning) if learning
54
+ end
55
+
56
+ # Supported Method Parameters::
57
+ # PWN::Backups.directory(label: 'maintenance')
58
+ # Caller must hold transaction through the final source mutation and ledger.
59
+ public_class_method def self.directory(opts = {})
60
+ label = opts[:label].to_s
61
+ raise ArgumentError, 'Invalid backup label' unless label.match?(/\A[a-zA-Z0-9_-]+\z/)
62
+
63
+ context = Thread.current[:pwn_backup_transaction]
64
+ raise IOError, 'Backup transaction required' unless context && context[:pid] == Process.pid && context[:thread] == Thread.current
65
+
66
+ unless context[:directory]
67
+ root = context[:root]
68
+ names = Dir.children(root).select { |name| File.directory?(File.join(root, name)) || File.symlink?(File.join(root, name)) }.sort
69
+ pins = names.length >= LIMIT ? reference_pins(root: root) : []
70
+ raise IOError, 'Pinned backup missing' unless (pins - names).empty?
71
+
72
+ candidates = names.select do |name|
73
+ next false unless name.match?(GENERATION) && !pins.include?(name)
74
+
75
+ path = File.join(root, name)
76
+ safe_directory(path: path)
77
+ state = File.join(path, '.generation.json')
78
+ next false unless File.file?(state) && !File.symlink?(state)
79
+
80
+ record = JSON.parse(File.binread(state))
81
+ # Capture pins that nothing references may be expired. Policy pins and
82
+ # evidence references are already in `pins`. Interrupted generations are not.
83
+ record['version'] == 1 && record['state'] == 'complete'
84
+ end
85
+ count = [names.length - LIMIT + 1, 0].max
86
+ raise IOError, 'Backup capacity occupied by legacy, pinned or interrupted generations' if candidates.length < count
87
+
88
+ candidates.sort_by { |name| File.mtime(File.join(root, name)).to_r }.first(count).each { |name| FileUtils.remove_entry_secure(File.join(root, name)) }
89
+ context[:directory] = File.join(root, "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}")
90
+ Dir.mkdir(context[:directory], 0o700)
91
+ write_state(context: context, state: 'prepared')
92
+ end
93
+ path = File.join(context[:directory], "#{label}-#{SecureRandom.hex(8)}")
94
+ Dir.mkdir(path, 0o700)
95
+ path
96
+ end
97
+
98
+ # Supported Method Parameters::
99
+ # PWN::Backups.capture(path: '/path/to/source', label: 'learning')
100
+ public_class_method def self.capture(opts = {})
101
+ source = File.expand_path(opts[:path])
102
+ label = opts[:label] || 'snapshot'
103
+ transaction do
104
+ Thread.current[:pwn_backup_transaction][:pinned] = true if opts[:pin]
105
+ raise IOError, 'Unsafe backup source' unless File.file?(source) && File.realpath(source) == source && File.stat(source).uid == Process.uid
106
+
107
+ directory = self.directory(label: label)
108
+ target = File.join(directory, File.basename(source))
109
+ before = Digest::SHA256.file(source).hexdigest
110
+ File.open(source, File::RDONLY | File::NOFOLLOW) do |input|
111
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |output|
112
+ IO.copy_stream(input, output)
113
+ output.flush
114
+ output.fsync
115
+ end
116
+ end
117
+ raise IOError, 'Backup source changed or copy failed' unless Digest::SHA256.file(source).hexdigest == before && Digest::SHA256.file(target).hexdigest == before
118
+
119
+ File.open(directory, File::RDONLY, &:fsync)
120
+ target
121
+ end
122
+ end
123
+
124
+ # Supported Method Parameters::
125
+ # PWN::Backups.configure(root: nil)
126
+ public_class_method def self.configure(opts = {})
127
+ root = opts[:root] || File.join(Dir.home, '.pwn', 'backup')
128
+ transaction(root: root) do
129
+ path = File.join(root, '.retention-policy.json')
130
+ raise IOError, 'Unsafe retention policy' if File.symlink?(path)
131
+
132
+ policy = File.file?(path) ? JSON.parse(File.binread(path)) : { 'version' => 1, 'pins' => [] }
133
+ raise IOError, 'Invalid retention policy' unless policy['version'] == 1 && policy['pins'].is_a?(Array)
134
+
135
+ updated = policy.merge('keep' => LIMIT, 'expire_history' => true, 'scope' => 'global')
136
+ next updated if updated == policy
137
+
138
+ temporary = "#{path}.#{SecureRandom.hex(8)}"
139
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
140
+ file.write(JSON.generate(updated))
141
+ file.flush
142
+ file.fsync
143
+ end
144
+ File.rename(temporary, path)
145
+ File.open(root, File::RDONLY, &:fsync)
146
+ updated
147
+ end
148
+ end
149
+
150
+ # Supported Method Parameters::
151
+ # PWN::Backups.reconcile(generation: 'name', expected_sha256: 'digest', confirmed: true)
152
+ public_class_method def self.reconcile(opts = {})
153
+ generation = opts[:generation].to_s
154
+ raise ArgumentError, 'Exact generation and explicit recovery confirmation required' unless generation.match?(GENERATION) && opts[:confirmed] == true
155
+
156
+ transaction do
157
+ root = Thread.current[:pwn_backup_transaction][:root]
158
+ directory = File.join(root, generation)
159
+ raise IOError, 'Recovery generation missing' unless File.directory?(directory)
160
+
161
+ safe_directory(path: directory)
162
+ path = File.join(directory, '.generation.json')
163
+ raise IOError, 'Recovery manifest changed' unless !File.symlink?(path) && Digest::SHA256.file(path).hexdigest == opts[:expected_sha256]
164
+
165
+ record = JSON.parse(File.read(path))
166
+ raise IOError, 'Unknown recovery manifest' unless record['version'] == 1 && %w[prepared complete].include?(record['state'])
167
+
168
+ write_state(context: { root: root, directory: directory, pinned: opts.fetch(:pinned, true) }, state: 'complete')
169
+ { generation: generation, state: 'complete', pinned: opts.fetch(:pinned, true) }
170
+ end
171
+ end
172
+
173
+ # Supported Method Parameters::
174
+ # PWN::Backups.relocate_legacy(paths: [], dry_run: true, writers_stopped: false)
175
+ public_class_method def self.relocate_legacy(opts = {})
176
+ paths = Array(opts[:paths]).map { |path| File.expand_path(path) }.uniq
177
+ dry_run = opts.fetch(:dry_run, true)
178
+ raise ArgumentError, 'Stop all legacy writers before relocation' unless dry_run || opts[:writers_stopped] == true
179
+
180
+ home = Dir.home
181
+ root = File.join(home, '.pwn')
182
+ paths.each do |path|
183
+ relative = path.delete_prefix("#{root}/")
184
+ recognized = relative.match?(%r{\A(?:learning\.jsonl\.bak[^/]*|preferences\.jsonl\.bak[^/]*|skills/.+/SKILL\.md\.[a-f0-9]{64}\.bak|policy\.json\.rollback-[a-f0-9]{64}\.json|learning/migrations/[a-f0-9]{64}\.backup\.jsonl|session_cleanup_archive/[^/]+|maintenance/(?:retirements|quarantine)/[^/]+)\z})
185
+ recognized ||= path == File.join(home, '.config', 'terminator', 'config.pwnbak')
186
+ raise ArgumentError, 'Unrecognized legacy recovery path' unless recognized
187
+ raise IOError, 'Unsafe legacy recovery path' unless File.realpath(path) == path && File.stat(path).uid == Process.uid
188
+ end
189
+ raise ArgumentError, 'Overlapping legacy sources' if paths.any? { |path| paths.any? { |other| path != other && path.start_with?("#{other}/") } }
190
+
191
+ return { planned: paths, moved: [] } if dry_run
192
+
193
+ transaction do
194
+ legacy_unreferenced(paths: paths, root: root)
195
+ mappings = paths.to_h do |path|
196
+ target = File.join(directory(label: 'legacy'), File.basename(path))
197
+ before = legacy_inventory(path: path)
198
+ FileUtils.cp_r(path, target, preserve: true)
199
+ raise IOError, 'Legacy recovery changed during copy' unless before == legacy_inventory(path: path) && before == legacy_inventory(path: target)
200
+
201
+ [path, { target: target, inventory: before }]
202
+ end
203
+ legacy_unreferenced(paths: paths, root: root)
204
+ mappings.each do |path, row|
205
+ raise IOError, 'Legacy recovery changed before relocation' unless row[:inventory] == legacy_inventory(path: path)
206
+ end
207
+ ledger = File.join(Thread.current[:pwn_backup_transaction][:directory], '.relocation.json')
208
+ File.open(ledger, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
209
+ file.write(JSON.generate(version: 1, mappings: mappings))
210
+ file.flush
211
+ file.fsync
212
+ end
213
+ mappings.each_key { |path| FileUtils.remove_entry_secure(path) }
214
+ { moved: mappings.transform_values { |row| row[:target] }, ledger: ledger }
215
+ end
216
+ end
217
+
218
+ private_class_method def self.legacy_inventory(opts = {})
219
+ path = opts[:path]
220
+ rows = {}
221
+ Find.find(path) do |file|
222
+ stat = File.lstat(file)
223
+ raise IOError, 'Unsafe legacy recovery entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
224
+
225
+ rows[file.delete_prefix(path)] = [stat.ftype, stat.mode, stat.file? ? Digest::SHA256.file(file).hexdigest : nil]
226
+ File.open(file, File::RDONLY, &:fsync)
227
+ end
228
+ rows
229
+ end
230
+
231
+ private_class_method def self.legacy_unreferenced(opts = {})
232
+ paths = opts[:paths]
233
+ root = opts[:root]
234
+ return unless File.directory?(root)
235
+
236
+ Find.find(root) do |file|
237
+ Find.prune if file == File.join(root, 'backup') || paths.include?(file)
238
+ raise IOError, 'Unsafe legacy reference scope' if File.symlink?(file)
239
+ next unless File.file?(file) && file.match?(/\.jsonl?\z/)
240
+
241
+ body = File.binread(file)
242
+ raise IOError, 'Legacy recovery is referenced; preserve original paths' if paths.any? { |path| body.include?(path) || body.include?(path.delete_prefix("#{root}/")) }
243
+ end
244
+ end
245
+
246
+ private_class_method def self.reference_pins(opts = {})
247
+ root = opts[:root]
248
+ policy = File.join(root, '.retention-policy.json')
249
+ raise IOError, 'Unsafe retention policy' if File.symlink?(policy)
250
+
251
+ pins = File.file?(policy) ? JSON.parse(File.binread(policy)).fetch('pins') : []
252
+ raise IOError, 'Invalid backup pins' unless pins.is_a?(Array) && pins.all? { |name| name.is_a?(String) && name.match?(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/) }
253
+
254
+ pins = pins.dup
255
+ state_root = File.dirname(root)
256
+ names = %w[learning learning.jsonl memory.json mistakes.json policy.json policy_traj.jsonl preferences.jsonl engagements artifacts sessions swarm jobs runs missions open_goals open_goal.json curriculum finetune extrospection skill_review.jsonl maintenance]
257
+ names.each do |name|
258
+ path = File.join(state_root, name)
259
+ next unless File.exist?(path) || File.symlink?(path)
260
+
261
+ Find.find(path) do |file|
262
+ stat = File.lstat(file)
263
+ raise IOError, 'Unsafe backup reference entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
264
+ next unless stat.file? && file.match?(/\.jsonl?\z/)
265
+
266
+ admission = Thread.current[:pwn_maintenance_admission]
267
+ raise IOError, 'Backup reference budget exhausted' if admission && !admission.call(stat.size + 1)
268
+
269
+ values = file.end_with?('.jsonl') ? File.foreach(file).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } : [JSON.parse(File.binread(file))]
270
+ values.select! { |value| value.is_a?(Hash) && (%w[prepared interrupted].include?(value['state']) || value['pinned'] == true) } if name == 'maintenance'
271
+ until values.empty?
272
+ value = values.pop
273
+ case value
274
+ when Hash then values.concat(value.values)
275
+ when Array then values.concat(value)
276
+ when String
277
+ match = value.match(%r{\A(?:#{Regexp.escape(root)}/|backup/)(\d{8}_\d{6}(?:_[a-f0-9]{8})?)(?:/|\z)})
278
+ pins << match[1] if match
279
+ end
280
+ end
281
+ end
282
+ end
283
+ pins.uniq
284
+ rescue JSON::ParserError, KeyError => e
285
+ raise IOError, "Unreadable backup references: #{e.class}"
286
+ end
287
+
288
+ private_class_method def self.safe_directory(opts = {})
289
+ path = opts[:path]
290
+ ancestor = path
291
+ loop do
292
+ raise IOError, 'Symlinked backup ancestor' if File.symlink?(ancestor)
293
+ break if File.dirname(ancestor) == ancestor
294
+
295
+ ancestor = File.dirname(ancestor)
296
+ end
297
+ FileUtils.mkdir_p(path, mode: 0o700)
298
+ stat = File.stat(path)
299
+ if stat.uid == Process.uid && !stat.mode.nobits?(0o077)
300
+ File.chmod(0o700, path)
301
+ stat = File.stat(path)
302
+ end
303
+ raise IOError, 'Unsafe backup directory' unless stat.uid == Process.uid && stat.mode.nobits?(0o077)
304
+
305
+ path
306
+ end
307
+
308
+ private_class_method def self.write_state(opts = {})
309
+ context = opts[:context]
310
+ path = File.join(context[:directory], '.generation.json')
311
+ temporary = "#{path}.tmp"
312
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
313
+ file.write(JSON.generate(version: 1, state: opts[:state], pinned: context[:pinned] == true))
314
+ file.flush
315
+ file.fsync
316
+ end
317
+ File.rename(temporary, path)
318
+ File.open(context[:directory], File::RDONLY, &:fsync)
319
+ File.open(context[:root], File::RDONLY, &:fsync)
320
+ end
321
+
322
+ # Author(s):: 0day Inc. <support@0dayinc.com>
323
+ public_class_method def self.authors
324
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
325
+ end
326
+
327
+ # Display Usage for this Module
328
+ public_class_method def self.help
329
+ puts "
330
+ # Hold the shared lock through backup and source mutation.
331
+ #{self}.transaction(root: 'optional - backup root; defaults to Thread.current[:pwn_backup_root] or ~/.pwn/backup') { ... }
332
+ # Allocate a producer directory inside the current generation.
333
+ #{self}.directory(label: 'required - producer label')
334
+ # Copy and hash-verify an owned regular file.
335
+ #{self}.capture(path: 'required - source path', label: 'optional - producer label', pin: 'optional - retain until explicitly reconciled')
336
+ # Install the global seven-folder policy without expiring existing data.
337
+ #{self}.configure(root: 'optional - backup root; defaults ~/.pwn/backup')
338
+ # Acknowledge completed store-specific recovery; this does not restore data.
339
+ #{self}.reconcile(generation: 'required - exact generation name', expected_sha256: 'required - inspected generation manifest digest', confirmed: 'required - explicit true after store recovery', pinned: 'optional - retain by default; false permits ordinary retention')
340
+ # Explicitly relocate recognized unreferenced legacy recovery copies.
341
+ #{self}.relocate_legacy(paths: 'required - exact legacy paths', dry_run: 'optional - preview only by default', writers_stopped: 'optional - must be true to apply')
342
+ # Check whether this thread owns the backup transaction.
343
+ #{self}.active?
344
+ # Show module authors.
345
+ #{self}.authors
346
+ "
347
+ end
348
+ end
349
+ end
data/lib/pwn/config.rb CHANGED
@@ -364,13 +364,20 @@ module PWN
364
364
 
365
365
  # Supported Method Parameters::
366
366
  # env = PWN::Config.default_env(
367
- # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
367
+ # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
368
+ # pwn_dec_path: 'optional - Legacy decryptor path; defaults to the vault path plus .decryptor',
369
+ # yubikey: 'optional - Explicit hardware opt-in with slot, serial, reader, device_path, connection, timeout selector'
368
370
  # )
369
371
  public_class_method def self.default_env(opts = {})
370
- pwn_env_path = opts[:pwn_env_path]
371
- pwn_dec_path = "#{pwn_env_path}.decryptor"
372
-
373
- puts "
372
+ pwn_env_path = opts[:pwn_env_path] || File.join(Dir.home, '.pwn', 'pwn.yaml')
373
+ pwn_dec_path = opts[:pwn_dec_path] || "#{pwn_env_path}.decryptor"
374
+ yubikey = opts[:yubikey]
375
+ raise ArgumentError, 'yubikey must be a selector Hash' unless yubikey.nil? || yubikey.is_a?(Hash)
376
+
377
+ if yubikey
378
+ puts "[*] Writing YubiKey-protected PWN::Env to #{pwn_env_path}; no software decryptor is created."
379
+ else
380
+ puts "
374
381
  [*] NOTICE:
375
382
  1. Writing minimal PWN::Env to:
376
383
  #{pwn_env_path}
@@ -381,15 +388,22 @@ module PWN
381
388
  4. For optimal security, it's recommended to move:
382
389
  #{pwn_dec_path}
383
390
  to a secure location and use the --pwn-dec parameter for PWN drivers.
384
- "
391
+ "
392
+ end
385
393
  env = env_template
386
394
 
387
- # Remove beginning colon from key names
388
-
389
- yaml_env = YAML.dump(env).gsub(/^(\s*):/, '\1')
390
- File.write(pwn_env_path, yaml_env)
391
- # Change file permission to 600
392
- File.chmod(0o600, pwn_env_path)
395
+ # YAML tags preserve symbol keys without rewriting multiline scalar bytes.
396
+ yaml_env = YAML.dump(env)
397
+ FileUtils.mkdir_p(File.dirname(pwn_env_path), mode: 0o700)
398
+ if yubikey
399
+ encrypted = PWN::Plugins::Vault.encrypt_data(data: yaml_env, yubikey: yubikey)
400
+ File.open(pwn_env_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(encrypted) }
401
+ else
402
+ File.write(pwn_env_path, yaml_env)
403
+ # Change file permission to 600
404
+ File.chmod(0o600, pwn_env_path)
405
+ PWN::Plugins::Vault.create(file: pwn_env_path, decryptor_file: pwn_dec_path)
406
+ end
393
407
 
394
408
  # Ensure skills dir for pwn-ai agent (in parent of pwn_env_path)
395
409
  pwn_env_root = File.dirname(pwn_env_path)
@@ -401,11 +415,6 @@ module PWN
401
415
  pwn_dec_path: pwn_dec_path
402
416
  }
403
417
 
404
- PWN::Plugins::Vault.create(
405
- file: pwn_env_path,
406
- decryptor_file: pwn_dec_path
407
- )
408
-
409
418
  Pry.config.refresh_pwn_env = false if defined?(Pry)
410
419
  env[:pwn_skills_path] = pwn_skills_path
411
420
  PWN::Config.install_default_skills(pwn_skills_path: pwn_skills_path)
@@ -430,6 +439,7 @@ module PWN
430
439
  # )
431
440
  public_class_method def self.redact_sensitive_artifacts(opts = {})
432
441
  config = opts[:config] ||= PWN::Env
442
+ return config.map { |value| value.is_a?(Hash) || value.is_a?(Array) ? redact_sensitive_artifacts(config: value) : value } if config.is_a?(Array)
433
443
 
434
444
  sensitive_keys = %i[
435
445
  admin_key
@@ -445,14 +455,38 @@ module PWN
445
455
  refresh_token
446
456
  secret_key
447
457
  token
458
+ pin
459
+ new_pin
460
+ admin_pin
461
+ puk
462
+ new_puk
463
+ management_key
464
+ new_management_key
465
+ management_password
466
+ access_code
467
+ new_access_code
468
+ oath_secret
469
+ oath_password
470
+ unlock_code
471
+ unlock_password
472
+ lock_code
473
+ new_lock_code
474
+ reset_code
475
+ scp
476
+ scp_key
477
+ scp_keys
478
+ scp03_key
479
+ scp11_key
480
+ static_password
481
+ hmac_secret
448
482
  ]
449
483
 
450
484
  # Transform values at the current level: redact sensitive keys
451
485
  config.transform_values.with_index do |v, k|
452
- if sensitive_keys.include?(config.keys[k])
486
+ if sensitive_keys.include?(config.keys[k].to_s.downcase.tr('-', '_').to_sym)
453
487
  '>>> REDACTED >>> USE `pwn-vault` FOR ADMINISTRATION <<< REDACTED <<<'
454
488
  else
455
- v.is_a?(Hash) ? redact_sensitive_artifacts(config: v) : v
489
+ v.is_a?(Hash) || v.is_a?(Array) ? redact_sensitive_artifacts(config: v) : v
456
490
  end
457
491
  end
458
492
  rescue StandardError => e
@@ -477,7 +511,8 @@ module PWN
477
511
  # Supported Method Parameters::
478
512
  # PWN::Config.refresh_env(
479
513
  # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
480
- # pwn_dec_path: 'optional - Path to pwn.yaml.decryptor file. Defaults to ~/.pwn/pwn.yaml.decryptor'
514
+ # pwn_dec_path: 'optional - Legacy decryptor path; not needed for hardware vaults',
515
+ # yubikey: 'optional - Hardware selector override or explicit opt-in for a new vault'
481
516
  # )
482
517
 
483
518
  # Deep-fill missing Hash keys from defaults into target (mutates target).
@@ -541,27 +576,20 @@ module PWN
541
576
  pwn_skills_path = File.join(pwn_env_root, 'skills')
542
577
  FileUtils.mkdir_p(pwn_skills_path)
543
578
 
544
- return default_env(pwn_env_path: pwn_env_path) unless File.exist?(pwn_env_path)
579
+ return default_env(pwn_env_path: pwn_env_path, pwn_dec_path: opts[:pwn_dec_path], yubikey: opts[:yubikey]) unless File.exist?(pwn_env_path)
545
580
 
546
581
  is_encrypted = PWN::Plugins::Vault.file_encrypted?(file: pwn_env_path)
547
582
  raise "PWN Environment (#{pwn_env_path}) is not encrypted! Use PWN::Vault.create(file: '#{pwn_env_path}', decryptor_file: '#{pwn_env_path}.decryptor') to encrypt it." unless is_encrypted
548
583
 
549
584
  pwn_dec_path = opts[:pwn_dec_path] ||= "#{pwn_env_path}.decryptor"
550
- raise "PWN Decryptor (#{pwn_dec_path}) does not exist!" unless File.exist?(pwn_dec_path)
551
-
552
- pwn_decryptor = YAML.load_file(pwn_dec_path, symbolize_names: true)
553
-
554
- key = opts[:key] ||= pwn_decryptor[:key] ||= ENV.fetch('PWN_DECRYPTOR_KEY')
555
- key = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Decryption Key') if key.nil?
556
-
557
- iv = opts[:iv] ||= pwn_decryptor[:iv] ||= ENV.fetch('PWN_DECRYPTOR_IV')
558
- iv = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Decryption IV') if iv.nil?
559
-
560
- env = PWN::Plugins::Vault.dump(
585
+ access = PWN::Plugins::Vault.access_options(
561
586
  file: pwn_env_path,
562
- key: key,
563
- iv: iv
587
+ decryptor_file: pwn_dec_path,
588
+ key: opts[:key],
589
+ iv: opts[:iv],
590
+ yubikey: opts[:yubikey]
564
591
  )
592
+ env = PWN::Plugins::Vault.dump(access.merge(file: pwn_env_path))
565
593
 
566
594
  valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :agent }.map(&:downcase)
567
595
 
@@ -1142,7 +1170,9 @@ module PWN
1142
1170
 
1143
1171
  # Run default env and return its result
1144
1172
  #{self}.default_env(
1145
- pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
1173
+ pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml',
1174
+ pwn_dec_path: 'optional - Legacy decryptor path; defaults to the vault path plus .decryptor',
1175
+ yubikey: 'optional - Explicit hardware opt-in with slot, serial, reader, device_path, connection, timeout selector; no sidecar'
1146
1176
  )
1147
1177
 
1148
1178
  # Run redact sensitive artifacts and return its result
@@ -1155,10 +1185,11 @@ module PWN
1155
1185
 
1156
1186
  # Run refresh env and return its result
1157
1187
  #{self}.refresh_env(
1158
- pwn_env_path: 'required - pwn env path value consumed by #refresh_env',
1159
- pwn_dec_path: 'required - pwn dec path value consumed by #refresh_env',
1160
- key: 'optional - key value consumed by #refresh_env',
1161
- iv: 'optional - iv value consumed by #refresh_env'
1188
+ pwn_env_path: 'optional - Configuration vault path; defaults to ~/.pwn/pwn.yaml',
1189
+ pwn_dec_path: 'optional - Legacy decryptor path; not needed for hardware vaults',
1190
+ key: 'optional - Legacy vault key override',
1191
+ iv: 'optional - Legacy vault IV override',
1192
+ yubikey: 'optional - Hardware selector with slot, serial, reader, device_path, connection, timeout; opts in for new vaults'
1162
1193
  )
1163
1194
 
1164
1195
  # Run pwn skills path and return its result