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/plugins.rb CHANGED
@@ -111,6 +111,7 @@ module PWN
111
111
  autoload :Volatility, 'pwn/plugins/volatility'
112
112
  autoload :Vsphere, 'pwn/plugins/vsphere'
113
113
  autoload :XXD, 'pwn/plugins/xxd'
114
+ autoload :YubiKey, 'pwn/plugins/yubikey'
114
115
  autoload :Zaproxy, 'pwn/plugins/zaproxy'
115
116
 
116
117
  # Display a List of Every PWN::Plugins Module
data/lib/pwn/redaction.rb CHANGED
@@ -4,7 +4,7 @@ require 'digest'
4
4
  require 'json'
5
5
 
6
6
  module PWN
7
- # Write-boundary redaction; reject cyclic or over-100-level containers before inspecting secrets. Never opens a credential store.
7
+ # Write-boundary redaction; redact never opens a credential store. Capture optionally stores ordinary secrets. Hardware/MFA fields use constant masks, never digest markers or loot capture.
8
8
  module Redaction
9
9
  PATTERNS = {
10
10
  pem: /-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)/m,
@@ -17,6 +17,15 @@ module PWN
17
17
  cookie: /\bSet-Cookie:\s*(?!\[REDACTED:)[^\r\n]+/i
18
18
  }.freeze
19
19
  SECRET_FIELD = /\A(?:password|passwd|secret|api[_-]?key|authorization|proxy-authorization|access[_-]?token|refresh[_-]?token|private[_-]?key|client[_-]?secret)\z/i
20
+ CONSTANT_MASK = '[REDACTED]'
21
+ MASKED_NAME = /(?:
22
+ (?:(?:new|current|old|admin|user|fido2?|piv|openpgp)[_-])?(?:pin|puk) |
23
+ access[_-]?code | management[_-]?(?:key|password) | oath[_-]?secret | static[_-]?password |
24
+ scp(?:03|11)?(?:[_-](?:key|keys|enc|mac|dek)(?:[_-]key)?)? |
25
+ (?:mfa|otp|totp|hotp)(?:[_-](?:code|token|secret))? | (?:recovery|verification)[_-]?code
26
+ )/ix
27
+ MASKED_FIELD = /\A#{MASKED_NAME}\z/
28
+ MASKED_TEXT = /(?<![\w-])((?:--)?["']?#{MASKED_NAME}["']?\s*(?:=>|[:=])\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[REDACTED(?::[^\]]+)?\]|[^\s,;}\])]+)/
20
29
 
21
30
  public_class_method def self.redact(opts = {})
22
31
  value = opts[:value]
@@ -46,7 +55,9 @@ module PWN
46
55
  case value
47
56
  when Hash
48
57
  value.to_h do |key, item|
49
- clean = if key.to_s.match?(SECRET_FIELD) && !item.nil?
58
+ clean = if key.to_s.match?(MASKED_FIELD) && !item.nil?
59
+ CONSTANT_MASK
60
+ elsif key.to_s.match?(SECRET_FIELD) && !item.nil?
50
61
  token(kind: key.to_s.downcase, value: item.to_s)
51
62
  else
52
63
  redact_value(value: item)
@@ -55,15 +66,58 @@ module PWN
55
66
  end
56
67
  when Array then value.map { |item| redact_value(value: item) }
57
68
  when String
58
- PATTERNS.reduce(value.dup) do |text, (kind, regex)|
59
- text.split(/(\[REDACTED:[^:\]]+:[0-9a-f]{8}\])/).map do |part|
60
- part.start_with?('[REDACTED:') ? part : part.gsub(regex) { |match| token(kind: kind, value: match) }
69
+ PATTERNS.reduce(mask_fields(text: value)) do |text, (kind, regex)|
70
+ text.split(/(\[REDACTED(?::[^:\]]+:[0-9a-f]{8})?\])/).map do |part|
71
+ part.start_with?('[REDACTED') ? part : part.gsub(regex) { |match| token(kind: kind, value: match) }
61
72
  end.join
62
73
  end
63
74
  else value
64
75
  end
65
76
  end
66
77
 
78
+ private_class_method def self.mask_fields(opts = {})
79
+ text = opts[:text]
80
+ clean = +''
81
+ offset = 0
82
+ while (match = MASKED_TEXT.match(text, offset))
83
+ clean << text[offset...match.begin(0)] << match[1] << CONSTANT_MASK
84
+ offset = masked_value_end(text: text, start: match.end(1), finish: match.end(0))
85
+ end
86
+ clean << text[offset..]
87
+ end
88
+
89
+ # Iterative scan prevents partial masks of serialized arrays/hashes of keys.
90
+ private_class_method def self.masked_value_end(opts = {})
91
+ text = opts[:text]
92
+ start = opts[:start]
93
+ pairs = { '[' => ']', '{' => '}' }
94
+ return opts[:finish] unless pairs.key?(text[start]) || ['"', "'"].include?(text[start])
95
+
96
+ stack = []
97
+ quote = nil
98
+ escaped = false
99
+ text[start..].each_char.with_index(start) do |char, index|
100
+ if quote
101
+ if escaped
102
+ escaped = false
103
+ elsif char == '\\'
104
+ escaped = true
105
+ elsif char == quote
106
+ quote = nil
107
+ return index + 1 if stack.empty?
108
+ end
109
+ elsif ['"', "'"].include?(char)
110
+ quote = char
111
+ elsif pairs.key?(char)
112
+ stack << pairs[char]
113
+ elsif pairs.value?(char)
114
+ return text.length unless stack.pop == char
115
+ return index + 1 if stack.empty?
116
+ end
117
+ end
118
+ text.length
119
+ end
120
+
67
121
  public_class_method def self.token(opts = {})
68
122
  value = opts[:value].to_s
69
123
  return value if value.match?(/\A\[REDACTED:[^:]+:[0-9a-f]{8}\]\z/)
@@ -75,19 +129,21 @@ module PWN
75
129
  value = opts[:value]
76
130
  return { redacted: redact(value: value), refs: [] } unless value.is_a?(String)
77
131
 
78
- text = value.to_s
132
+ text = mask_fields(text: value)
79
133
  refs = []
80
134
  redacted = PATTERNS.reduce(text.dup) do |acc, (kind, regex)|
81
135
  acc.gsub(regex) do |match|
136
+ next match if match.match?(/\A[\w-]+\s*[:=]\s*\[REDACTED\]\z/)
137
+
82
138
  tok = token(kind: kind, value: match)
83
139
  refs << { kind: kind, token: tok }
84
- PWN::Plugins::Vault.store(label: tok, secret: match, engagement: opts[:engagement]) if opts[:vault] != false && defined?(PWN::Plugins::Vault)
140
+ PWN::Plugins::Vault.store(label: tok, secret: match, engagement: opts[:engagement], yubikey: opts[:yubikey]) if opts[:vault] != false && defined?(PWN::Plugins::Vault)
85
141
  tok
86
142
  rescue StandardError
87
143
  tok
88
144
  end
89
145
  end
90
- redacted = capture_entropy(text: redacted, refs: refs, vault: opts[:vault], engagement: opts[:engagement]) if opts[:entropy] == true
146
+ redacted = capture_entropy(text: redacted, refs: refs, vault: opts[:vault], engagement: opts[:engagement], yubikey: opts[:yubikey]) if opts[:entropy] == true
91
147
  { redacted: redacted, refs: refs }
92
148
  end
93
149
 
@@ -100,10 +156,10 @@ module PWN
100
156
 
101
157
  tok = token(kind: 'entropy', value: match)
102
158
  refs << { kind: 'entropy', token: tok }
103
- PWN::Plugins::Vault.store(label: tok, secret: match, engagement: opts[:engagement]) if opts[:vault] != false && defined?(PWN::Plugins::Vault)
159
+ PWN::Plugins::Vault.store(label: tok, secret: match, engagement: opts[:engagement], yubikey: opts[:yubikey]) if opts[:vault] != false && defined?(PWN::Plugins::Vault)
104
160
  tok
105
161
  rescue StandardError
106
- match
162
+ tok || token(kind: 'entropy', value: match)
107
163
  end
108
164
  end
109
165
 
@@ -122,7 +178,7 @@ module PWN
122
178
 
123
179
  public_class_method def self.help
124
180
  puts "USAGE:
125
- # Redact secret patterns before persistence; reject cycles/depth above 100 with JSON::NestingError.
181
+ # Redact before persistence; hardware/MFA fields use constant masks. Reject cycles/depth above 100 with JSON::NestingError.
126
182
  #{self}.redact(
127
183
  value: 'required - string or nested JSON-compatible data'
128
184
  )
@@ -131,10 +187,11 @@ module PWN
131
187
  kind: 'required - secret type label',
132
188
  value: 'required - sensitive value to hash'
133
189
  )
134
- # Capture secrets into Vault labels and return a redacted copy.
190
+ # Capture ordinary secrets into Vault labels; mask hardware/MFA fields without storing them.
135
191
  #{self}.capture(
136
192
  value: 'required - string that may contain credentials',
137
193
  vault: 'optional - false skips Vault.store',
194
+ yubikey: 'optional - selector Hash for hardware-encrypted captured loot; no software fallback on failure',
138
195
  engagement: 'optional - engagement name stored with the vault label',
139
196
  entropy: 'optional - true also captures high-entropy tokens'
140
197
  )
@@ -91,6 +91,10 @@ module PWN
91
91
  timeout = Float(opts.fetch(:timeout, 30))
92
92
  raise ArgumentError, 'Invalid rerun deadline' unless timeout.positive? && timeout <= 300
93
93
 
94
+ # A user-namespace caller that is not uid 0 may still hold capabilities.
95
+ # Bubblewrap rejects those. Uid 0 can already create the nested map;
96
+ # dropping every capability there makes the uid-map setup fail.
97
+ argv.unshift('setpriv', '--ambient-caps=-all', '--inh-caps=-all', '--bounding-set=-all', '--') if PWN::AI::Agent::MissionNamespace.prepared? && !Process.uid.zero?
94
98
  Open3.popen3(*argv, unsetenv_others: true, pgroup: true) do |stdin, stdout, stderr, wait|
95
99
  readers = [Thread.new { stdout.read }, Thread.new { stderr.read }]
96
100
  begin
data/lib/pwn/sessions.rb CHANGED
@@ -88,11 +88,13 @@ module PWN
88
88
  when :trace then ['traces', File.join(id, 'trace.jsonl')]
89
89
  else raise ArgumentError, 'Invalid session support kind'
90
90
  end
91
- legacy = File.join(sessions_dir, name)
91
+ directory = opts[:read_only] ? SESSIONS_DIR : sessions_dir
92
+ legacy = File.join(directory, name)
92
93
  return legacy if File.exist?(legacy) || File.symlink?(legacy)
93
94
 
94
- path = File.join(sessions_dir, 'support', folder, name)
95
- return legacy unless File.exist?(path) || File.symlink?(path) || File.file?(File.join(sessions_dir, '.layout-v2'))
95
+ path = File.join(directory, 'support', folder, name)
96
+ return legacy unless File.exist?(path) || File.symlink?(path) || File.file?(File.join(directory, '.layout-v2'))
97
+ return path if opts[:read_only]
96
98
 
97
99
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
98
100
  raise IOError, 'Unsafe session support directory' unless File.realpath(File.dirname(path)) == File.expand_path(File.dirname(path))
@@ -183,11 +185,31 @@ module PWN
183
185
  files.concat(Dir.glob(File.join(root, 'artifacts', '{manifest.jsonl,*/index.jsonl,*/engagement-memory.json}')))
184
186
  databases = Dir.glob(File.join(root, 'embeddings', '*.db'))
185
187
  errors.concat(%w[artifacts embeddings].map { |name| File.join(root, name) }.select { |dir| File.symlink?(dir) || (File.exist?(dir) && !File.directory?(dir)) })
188
+ admission = Thread.current[:pwn_maintenance_admission]
186
189
  directories.each do |dir|
187
190
  next unless File.directory?(dir) && !File.symlink?(dir)
188
191
 
189
- files.concat(Dir.glob(File.join(dir, '**', '*.{json,jsonl}')).reject { |path| File.basename(path) == 'loot.json' })
190
- errors.concat(Dir.glob(File.join(dir, '**', '*')).select { |path| File.symlink?(path) })
192
+ pending = [[dir, Dir.each_child(dir)]]
193
+ until pending.empty?
194
+ begin
195
+ path = File.join(pending.last[0], pending.last[1].next)
196
+ rescue StopIteration
197
+ pending.pop
198
+ next
199
+ end
200
+ if admission && !admission.call
201
+ errors << 'incomplete_dependency_index'
202
+ break
203
+ end
204
+ if File.symlink?(path)
205
+ errors << path
206
+ elsif File.directory?(path)
207
+ pending << [path, Dir.each_child(path)]
208
+ elsif %w[.json .jsonl].include?(File.extname(path)) && File.basename(path) != 'loot.json'
209
+ files << path
210
+ end
211
+ end
212
+ break if errors.include?('incomplete_dependency_index')
191
213
  end
192
214
  dependencies = {}
193
215
  evidence_dependencies = {}
@@ -206,12 +228,20 @@ module PWN
206
228
  errors << path
207
229
  end
208
230
  files.uniq.sort.each do |path|
231
+ if admission && !admission.call
232
+ errors << 'incomplete_dependency_index'
233
+ break
234
+ end
209
235
  next unless File.exist?(path) || File.symlink?(path)
210
236
 
211
237
  begin
212
238
  raise IOError, 'Unsafe dependency parent' if errors.any? { |parent| path.start_with?("#{parent}/") }
213
239
  raise IOError, 'Unsafe dependency store' unless File.file?(path) && File.realpath(path) == File.expand_path(path)
214
240
 
241
+ if admission && !admission.call(File.size(path))
242
+ errors << 'incomplete_dependency_index'
243
+ break
244
+ end
215
245
  bytes = File.binread(path)
216
246
  units = dependency_units(path: path, bytes: bytes)
217
247
  units.each do |locator, record|
@@ -560,9 +590,12 @@ module PWN
560
590
  sid = opts[:session_id].to_s
561
591
  raise ArgumentError, 'Invalid session ID' if sid.empty? || File.basename(sid) != sid || sid == '.' || sid == '..'
562
592
 
563
- File.open(support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
593
+ path = support_path(kind: :lease, session_id: sid, read_only: true)
594
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |lock|
564
595
  !lock.flock(File::LOCK_EX | File::LOCK_NB)
565
596
  end
597
+ rescue Errno::ENOENT
598
+ false
566
599
  end
567
600
 
568
601
  # Supported Method Parameters::
@@ -752,7 +785,9 @@ module PWN
752
785
  # Character chunks keep Unicode intact, including four-byte characters.
753
786
  text.chars.each_slice(1500) do |chunk|
754
787
  output = PWN::AI::Router.summarize(text: "Previous overall summary: #{result[:summary]}\nNew conversation turns:\n#{chunk.join}", llm: true, session: true)
755
- parsed = JSON.parse(output.to_s.sub(/\A```(?:json)?\s*/, '').sub(/\s*```\z/, ''), symbolize_names: true)
788
+ raise IOError, 'Session title provider returned no text' if output.to_s.strip.empty?
789
+
790
+ parsed = JSON.parse(output.to_s.strip.sub(/\A```(?:json)?\s*/, '').sub(/\s*```\z/, ''), symbolize_names: true)
756
791
  raise ArgumentError, 'Invalid summary response' unless parsed.is_a?(Hash) && parsed[:title].is_a?(String) && parsed[:summary].is_a?(String)
757
792
  raise ArgumentError, 'Invalid summary title' if !summary_title_valid?(title: parsed[:title]) || parsed[:summary].strip.empty?
758
793
 
@@ -835,7 +870,14 @@ module PWN
835
870
  content: content,
836
871
  timestamp: Time.now.utc.iso8601
837
872
  }
838
- File.open(path, 'a') { |f| f.puts(JSON.dump(entry)) }
873
+ File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |writer|
874
+ writer.flock(File::LOCK_EX)
875
+ File.open(path, File::WRONLY | File::APPEND | File::NOFOLLOW) do |file|
876
+ file.puts(JSON.dump(entry))
877
+ file.flush
878
+ file.fsync
879
+ end
880
+ end
839
881
  provisional_title(session_id: sid, request: content) if role == 'user'
840
882
  entry
841
883
  ensure
@@ -1236,12 +1278,10 @@ module PWN
1236
1278
  return false unless File.exist?(path)
1237
1279
 
1238
1280
  lease = activate(session_id: File.basename(path, '.jsonl'))
1281
+ writer = File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- held through atomic replace; ensure closes it
1282
+ writer.flock(File::LOCK_EX)
1239
1283
  lines = File.readlines(path)
1240
- rows = lines.map do |l|
1241
- JSON.parse(l, symbolize_names: true)
1242
- rescue StandardError
1243
- nil
1244
- end.compact
1284
+ rows = lines.map { |line| JSON.parse(line, symbolize_names: true) }
1245
1285
  return false if rows.empty?
1246
1286
 
1247
1287
  # Preserve last assistant fully-ish; compact tools and older assistants
@@ -1266,17 +1306,19 @@ module PWN
1266
1306
  end
1267
1307
  return changed if dry || !changed
1268
1308
 
1269
- tmp = "#{path}.#{Process.pid}.tmp"
1270
- File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
1309
+ tmp = "#{path}.#{SecureRandom.hex(12)}.tmp"
1310
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |f|
1271
1311
  f.flock(File::LOCK_EX)
1272
1312
  rows.each { |e| f.puts(JSON.generate(e)) }
1273
1313
  f.flush
1274
1314
  f.fsync
1275
1315
  end
1276
1316
  File.rename(tmp, path)
1317
+ File.open(File.dirname(path), File::RDONLY, &:fsync)
1277
1318
  true
1278
1319
  ensure
1279
1320
  FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
1321
+ writer&.close
1280
1322
  deactivate(lease: lease)
1281
1323
  end
1282
1324
 
@@ -1358,7 +1400,7 @@ module PWN
1358
1400
  #{self}.list(include_hidden: 'optional - include unretained sessions for diagnostics and explicit resume', current_session_id: 'optional - include this active session regardless of naming classification', references: 'optional - precomputed structural dependency index')
1359
1401
 
1360
1402
  # Resolve housekeeping paths with legacy compatibility.
1361
- #{self}.support_path(kind: 'required - metadata, lease, owner or trace', session_id: 'required - support record identifier')
1403
+ #{self}.support_path(kind: 'required - metadata, lease, owner or trace', session_id: 'required - support record identifier', read_only: 'optional - resolve without creating session or support directories')
1362
1404
 
1363
1405
  # Page retained sessions separately from global store diagnostics.
1364
1406
  #{self}.catalog(offset: 'optional - nonnegative retained session offset, omitted selects newest page', limit: 'optional - page size from one to 200, defaults 25 plus active row if outside page', current_session_id: 'optional - active session id always included without granting retention')
data/lib/pwn/setup.rb CHANGED
@@ -129,10 +129,44 @@ module PWN
129
129
  }
130
130
  }.freeze
131
131
 
132
+ # Optional runtime libraries, not executables or Ruby build dependencies.
133
+ # macOS provides PC/SC as a system framework. Linux PC/SC also needs a
134
+ # daemon and CCID reader driver for actual I/O; doctor only loads libraries.
135
+ NATIVE_LIBRARIES = {
136
+ 'hidapi' => {
137
+ apt: %w[libhidapi-hidraw0], dnf: %w[hidapi], pacman: %w[hidapi],
138
+ brew: %w[hidapi], port: %w[hidapi],
139
+ libraries: {
140
+ unix: %w[libhidapi-hidraw.so.0 libhidapi-hidraw.so libhidapi-libusb.so.0 libhidapi-libusb.so libhidapi.so],
141
+ macos: %w[libhidapi.dylib /opt/homebrew/lib/libhidapi.dylib /usr/local/lib/libhidapi.dylib /opt/local/lib/libhidapi.dylib],
142
+ windows: %w[hidapi.dll]
143
+ },
144
+ plugins: %w[PWN::Plugins::YubiKey]
145
+ },
146
+ 'pcsc' => {
147
+ apt: %w[libpcsclite1 pcscd libccid], dnf: %w[pcsc-lite-libs pcsc-lite pcsc-lite-ccid],
148
+ pacman: %w[pcsclite ccid], brew: %w[], port: %w[],
149
+ libraries: {
150
+ unix: %w[libpcsclite.so.1 libpcsclite.so],
151
+ macos: %w[/System/Library/Frameworks/PCSC.framework/PCSC],
152
+ windows: %w[winscard.dll]
153
+ },
154
+ plugins: %w[PWN::Plugins::YubiKey]
155
+ }
156
+ }.freeze
157
+
132
158
  # External binaries PWN wraps → OS packages that provide them.
133
159
  # (Extrospection.probe_toolchain already knows how to *detect* these;
134
160
  # this table knows how to *install* them.)
135
161
  TOOLCHAIN = {
162
+ 'setpriv' => {
163
+ apt: %w[util-linux], dnf: %w[util-linux], pacman: %w[util-linux], brew: %w[], port: %w[],
164
+ plugins: %w[PWN::SessionTrace]
165
+ },
166
+ 'unshare' => {
167
+ apt: %w[util-linux], dnf: %w[util-linux], pacman: %w[util-linux], brew: %w[], port: %w[],
168
+ plugins: %w[PWN::AI::Agent::MissionNamespace]
169
+ },
136
170
  'nmap' => {
137
171
  apt: %w[nmap], dnf: %w[nmap], pacman: %w[nmap], brew: %w[nmap], port: %w[nmap],
138
172
  plugins: %w[PWN::Plugins::NmapIt]
@@ -408,7 +442,7 @@ module PWN
408
442
  ai: {
409
443
  desc: 'verify at least one AI engine key/oauth in ~/.pwn/pwn.yaml',
410
444
  gems: %w[],
411
- bins: %w[jq]
445
+ bins: %w[jq unshare setpriv]
412
446
  },
413
447
  web: {
414
448
  desc: 'TransparentBrowser · BurpSuite · Zaproxy · extro_verify · extro_watch',
@@ -446,9 +480,10 @@ module PWN
446
480
  bins: %w[msfconsole sqlmap]
447
481
  },
448
482
  hardware: {
449
- desc: 'Serial · BusPirate · Android · BareSIP · extro_serial · extro_telecomm',
483
+ desc: 'Serial · BusPirate · Android · BareSIP · YubiKey · extro_serial · extro_telecomm',
450
484
  gems: %w[libusb],
451
- bins: %w[adb baresip]
485
+ bins: %w[adb baresip],
486
+ libs: %w[hidapi pcsc]
452
487
  },
453
488
  re: {
454
489
  desc: 'Radare2 · GDB · ExploitDev · Frida · Android RE tier',
@@ -458,7 +493,8 @@ module PWN
458
493
  full: {
459
494
  desc: 'everything above',
460
495
  gems: NATIVE_GEMS.keys,
461
- bins: TOOLCHAIN.keys
496
+ bins: TOOLCHAIN.keys,
497
+ libs: NATIVE_LIBRARIES.keys
462
498
  }
463
499
  }.freeze
464
500
 
@@ -572,15 +608,23 @@ module PWN
572
608
  io.puts
573
609
 
574
610
  # ~/.pwn -----------------------------------------------------------
575
- pwn_root = File.join(Dir.home, '.pwn')
576
- pwn_yaml = File.join(pwn_root, 'pwn.yaml')
577
- pwn_dec = File.join(pwn_root, 'pwn.yaml.decryptor')
611
+ pwn_root = File.join(Dir.home, '.pwn')
612
+ driver = defined?(PWN::Env) && PWN::Env.is_a?(Hash) ? PWN::Env[:driver_opts] : nil
613
+ driver = {} unless driver.is_a?(Hash)
614
+ pwn_yaml = driver[:pwn_env_path] || File.join(pwn_root, 'pwn.yaml')
615
+ pwn_dec = driver[:pwn_dec_path] || "#{pwn_yaml}.decryptor"
578
616
  root_ok = File.directory?(pwn_root)
579
617
  root_note = root_ok ? "(#{Dir.children(pwn_root).length} entries)" : '(will be created on first `pwn` launch)'
580
618
  io.puts "#{'~/.pwn/'.ljust(26)} #{root_ok ? OK : MISS} #{root_note}"
581
619
  yaml_ok = File.file?(pwn_yaml)
582
620
  dec_state = File.file?(pwn_dec) ? 'present' : 'MISSING'
583
- yaml_note = yaml_ok ? "(encrypted, decryptor #{dec_state})" : '(run `pwn` once, then `pwn-vault` to configure)'
621
+ yaml_note = if !yaml_ok
622
+ '(run `pwn` once, then `pwn-vault` to configure)'
623
+ elsif PWN::Plugins::Vault.hardware_encrypted?(file: pwn_yaml)
624
+ '(YubiKey encrypted; no software decryptor required)'
625
+ else
626
+ "(encrypted, decryptor #{dec_state})"
627
+ end
584
628
  io.puts "#{'~/.pwn/pwn.yaml'.ljust(26)} #{yaml_ok ? OK : MISS} #{yaml_note}"
585
629
  engine = ai_active_engine
586
630
  key_ok = ai_key_configured?(engine: engine)
@@ -603,6 +647,20 @@ module PWN
603
647
  end
604
648
  io.puts
605
649
 
650
+ # Runtime libraries ------------------------------------------------
651
+ io.puts 'Native libraries (host loading only; no device discovery)'
652
+ lib_missing = []
653
+ NATIVE_LIBRARIES.each do |name, meta|
654
+ ok = native_library_available?(name: name, os: os)
655
+ lib_missing << name unless ok
656
+ packages = Array(meta[pm[:key]])
657
+ hint = packages.empty? ? 'native OS library; see hardware profile' : packages.join(' ')
658
+ need = ok ? '' : "(needs: #{hint})"
659
+ io.puts " #{name.ljust(14)} #{ok ? OK : MISS} #{need} → #{Array(meta[:plugins]).join(', ')}"
660
+ end
661
+ io.puts ' Library presence does not verify device permissions, PC/SC service, reader or token readiness.'
662
+ io.puts
663
+
606
664
  # Toolchain --------------------------------------------------------
607
665
  io.puts "#{'External toolchain'.ljust(46)} used by"
608
666
  bin_missing = []
@@ -621,36 +679,41 @@ module PWN
621
679
  io.puts
622
680
  end
623
681
 
624
- total = NATIVE_GEMS.size + TOOLCHAIN.size
625
- missing = gem_missing.size + bin_missing.size
682
+ total = NATIVE_GEMS.size + TOOLCHAIN.size + NATIVE_LIBRARIES.size
683
+ missing = gem_missing.size + bin_missing.size + lib_missing.size
626
684
  io.puts "#{total - missing} / #{total} capabilities usable · #{missing} degraded"
627
685
  io.puts
628
686
  unless missing.zero?
629
- io.puts 'Run `pwn setup --deps` to install missing OS headers/tools, or'
687
+ io.puts 'Run `pwn setup --deps` to install missing OS headers/tools/libraries, or'
630
688
  io.puts ' `pwn setup --profile <name>` for a subset. See `pwn setup --list-profiles`.'
631
689
  end
632
690
 
633
691
  # When a profile is given (`pwn setup --check --profile core`), :ok is
634
- # gated ONLY on that profile's gems/bins so a partially-provisioned host
692
+ # gated ONLY on that profile's gems/bins/libs so a partially-provisioned host
635
693
  # can still pass its own doctor gate. `packer/provisioners/pwn.sh` and
636
694
  # spec/integration/fresh_install_spec.rb rely on this — a `core` leg of
637
695
  # install-matrix must not fail because gqrx/burpsuite/etc. are absent.
638
696
  gate_gems = gem_missing
639
697
  gate_bins = bin_missing
698
+ gate_libs = lib_missing
640
699
  if profile && PROFILES.key?(profile)
641
700
  prof = PROFILES[profile]
642
701
  gate_gems = gem_missing & Array(prof[:gems])
643
702
  gate_bins = bin_missing & Array(prof[:bins])
703
+ gate_libs = lib_missing & Array(prof[:libs])
644
704
  io.puts
705
+ library_note = ", libs: #{gate_libs.empty? ? '—' : gate_libs.join(' ')}" unless Array(prof[:libs]).empty?
645
706
  io.puts "Profile '#{profile}' gate: " \
646
- "#{gate_gems.empty? && gate_bins.empty? ? OK : MISS} " \
707
+ "#{gate_gems.empty? && gate_bins.empty? && gate_libs.empty? ? OK : MISS} " \
647
708
  "(gems: #{gate_gems.empty? ? '—' : gate_gems.join(' ')}, " \
648
- "bins: #{gate_bins.empty? ? '—' : gate_bins.join(' ')})"
709
+ "bins: #{gate_bins.empty? ? '—' : gate_bins.join(' ')}" \
710
+ "#{library_note})"
649
711
  end
650
712
 
651
- { ok: gate_gems.empty? && gate_bins.empty? && Array(migrate && migrate[:incompatible]).empty?,
713
+ { ok: gate_gems.empty? && gate_bins.empty? && gate_libs.empty? && Array(migrate && migrate[:incompatible]).empty?,
652
714
  native_gems_missing: gem_missing, toolchain_missing: bin_missing,
653
- state: migrate, pkg_manager: pm[:key], os: os, arch: arch }
715
+ state: migrate, pkg_manager: pm[:key], os: os, arch: arch,
716
+ native_libraries_missing: lib_missing }
654
717
  rescue StandardError => e
655
718
  raise e
656
719
  end
@@ -690,6 +753,7 @@ module PWN
690
753
  bins = Array(prof[:bins])
691
754
  os_pkgs = []
692
755
  gems.each { |g| os_pkgs.concat(Array(NATIVE_GEMS.dig(g, pm[:key]))) }
756
+ Array(prof[:libs]).each { |lib| os_pkgs.concat(Array(NATIVE_LIBRARIES.dig(lib, pm[:key]))) }
693
757
  bins.each { |b| os_pkgs.concat(packages_for(bin: b, distro: distro, version: version, pm_key: pm[:key])) }
694
758
  os_pkgs = os_pkgs.reject(&:empty?).uniq
695
759
  alts = alt_install_cmds(bins: bins, pm_key: pm[:key], distro: distro, version: version)
@@ -765,7 +829,7 @@ module PWN
765
829
  # third_party/terminator/pwn_shift_enter.py to
766
830
  # ~/.config/terminator/plugins/ and enables the PWNShiftEnter
767
831
  # plugin in ~/.config/terminator/config — WITH confirmation and
768
- # a .pwnbak backup. This is the only VTE emulator we can fix at
832
+ # a shared recovery backup. This is the only VTE emulator we can fix at
769
833
  # the GTK layer without patching libvte itself.
770
834
  #
771
835
  # Everything here is host-config mutation, which is why it lives in
@@ -831,7 +895,7 @@ module PWN
831
895
  io.puts
832
896
  io.puts " write #{plugin_dst}"
833
897
  io.puts " edit #{cfg_path} (add PWNShiftEnter to enabled_plugins)"
834
- io.puts " backup #{cfg_path}.pwnbak"
898
+ io.puts ' backup ~/.pwn/backup (shared seven-generation store)'
835
899
  io.puts
836
900
 
837
901
  unless yes
@@ -858,7 +922,7 @@ module PWN
858
922
  if cfg.include?('PWNShiftEnter')
859
923
  io.puts " #{OK} PWNShiftEnter already enabled in #{cfg_path}"
860
924
  else
861
- FileUtils.cp(cfg_path, "#{cfg_path}.pwnbak") unless File.exist?("#{cfg_path}.pwnbak")
925
+
862
926
  new_cfg =
863
927
  if cfg =~ /^\s*enabled_plugins\s*=\s*(.*)$/
864
928
  cfg.sub(/^(\s*enabled_plugins\s*=\s*)(.*)$/) do
@@ -871,8 +935,11 @@ module PWN
871
935
  else
872
936
  "[global_config]\n enabled_plugins = PWNShiftEnter\n#{cfg}"
873
937
  end
874
- File.write(cfg_path, new_cfg)
875
- io.puts " #{OK} enabled PWNShiftEnter in #{cfg_path} (backup: config.pwnbak)"
938
+ PWN::Backups.transaction do
939
+ backup = PWN::Backups.capture(path: cfg_path, label: 'terminator')
940
+ File.write(cfg_path, new_cfg)
941
+ io.puts " #{OK} enabled PWNShiftEnter in #{cfg_path} (backup: #{backup})"
942
+ end
876
943
  end
877
944
  else
878
945
  FileUtils.mkdir_p(File.dirname(cfg_path))
@@ -900,6 +967,7 @@ module PWN
900
967
  io.puts " #{name.to_s.ljust(10)} #{meta[:desc]}"
901
968
  io.puts " #{''.ljust(10)} gems: #{meta[:gems].join(' ')}" unless meta[:gems].empty?
902
969
  io.puts " #{''.ljust(10)} bins: #{meta[:bins].join(' ')}" unless meta[:bins].empty?
970
+ io.puts " #{''.ljust(10)} libs: #{meta[:libs].join(' ')}" unless Array(meta[:libs]).empty?
903
971
  io.puts
904
972
  end
905
973
  PROFILES.keys
@@ -998,6 +1066,28 @@ module PWN
998
1066
  ''
999
1067
  end
1000
1068
 
1069
+ private_class_method def self.native_library_available?(opts = {})
1070
+ require 'ffi'
1071
+ os = opts[:os].to_s
1072
+ platform = if os.match?(/darwin|osx|macos/i)
1073
+ :macos
1074
+ elsif os.match?(/mswin|mingw|cygwin|windows/i)
1075
+ :windows
1076
+ else
1077
+ :unix
1078
+ end
1079
+ libraries = NATIVE_LIBRARIES.fetch(opts[:name]).fetch(:libraries).fetch(platform)
1080
+ flags = ::FFI::DynamicLibrary::RTLD_LAZY | ::FFI::DynamicLibrary::RTLD_LOCAL
1081
+ libraries.any? do |library|
1082
+ ::FFI::DynamicLibrary.open(library, flags)
1083
+ true
1084
+ rescue LoadError
1085
+ false
1086
+ end
1087
+ rescue LoadError
1088
+ false
1089
+ end
1090
+
1001
1091
  private_class_method def self.gem_loadable?(opts = {})
1002
1092
  name = opts[:name].to_s
1003
1093
  Gem::Specification.find_by_name(name)
@@ -1047,22 +1137,20 @@ module PWN
1047
1137
  end
1048
1138
 
1049
1139
  if dry_run
1050
- st = PWN::Cron.scheduler_status(apply: false)
1140
+ st = { worker: PWN::Cron.worker_status, backend: :worker }
1051
1141
  io.puts "#{'cron worker'.ljust(26)} #{st.dig(:worker, :running) ? OK : MISS} (dry-run, backend=#{st[:backend]}, enabled=#{enabled})"
1052
1142
  return { ok: true, dry_run: true, worker: st[:worker], backend: st[:backend], enabled: enabled }
1053
1143
  end
1054
1144
 
1055
1145
  unless enabled
1056
- native = begin
1057
- PWN::Cron.uninstall_scheduler(apply: opts.fetch(:apply, PWN::Cron.apply_native?))
1058
- rescue StandardError => e
1059
- io.puts "#{'cron scheduler'.ljust(26)} #{MISS} #{e.class}: #{e.message}"
1060
- { ok: false, error: "#{e.class}: #{e.message}" }
1061
- end
1146
+ PWN::Cron.persist_scheduler_config(enabled: false)
1147
+ worker = PWN::Cron.stop_worker
1148
+ native = { backend: :worker, worker: worker }
1062
1149
  io.puts "#{'cron scheduler'.ljust(26)} #{OK} disabled (#{native[:backend] || 'none'})"
1063
1150
  return { ok: true, enabled: false, native: native, seeded: [] }
1064
1151
  end
1065
1152
 
1153
+ PWN::Cron.persist_scheduler_config(enabled: true) if opts.key?(:enabled)
1066
1154
  seeded = begin
1067
1155
  PWN::Cron.install_defaults
1068
1156
  rescue StandardError => e
@@ -1078,6 +1166,7 @@ module PWN
1078
1166
  seeded: seeded,
1079
1167
  worker: live.merge(already_running: true),
1080
1168
  skipped_spawn: true,
1169
+ backend: :worker,
1081
1170
  enabled: true
1082
1171
  }
1083
1172
  end
@@ -1097,16 +1186,9 @@ module PWN
1097
1186
  mark = st[:running] ? OK : MISS
1098
1187
  io.puts "#{'cron worker'.ljust(26)} #{mark} #{label} pid=#{st[:pid] || worker[:pid] || 'none'}"
1099
1188
 
1100
- native = begin
1101
- PWN::Cron.install_scheduler(
1102
- enabled: true,
1103
- apply: opts.fetch(:apply, PWN::Cron.apply_native?)
1104
- )
1105
- rescue StandardError => e
1106
- io.puts "#{'cron scheduler'.ljust(26)} #{MISS} #{e.class}: #{e.message}"
1107
- { ok: false, error: "#{e.class}: #{e.message}" }
1108
- end
1109
- backend = native.is_a?(Hash) ? native[:backend] : nil
1189
+ # Setup never installs, starts, removes or repairs native schedulers.
1190
+ native = { ok: true, backend: :worker, persisted: false }
1191
+ backend = :worker
1110
1192
  io.puts "#{'cron scheduler'.ljust(26)} #{native[:ok] == false ? MISS : OK} backend=#{backend || 'none'}"
1111
1193
 
1112
1194
  {
@@ -1145,13 +1227,13 @@ module PWN
1145
1227
  pm_key: 'optional - package-manager family (:apt :dnf :pacman :brew :port :pkg)'
1146
1228
  )
1147
1229
 
1148
- # Run check and return its result
1230
+ # Check gems, binaries and native host libraries; native_libraries_missing reports HIDAPI/PCSC without device discovery.
1149
1231
  #{self}.check(
1150
1232
  io: 'optional - IO to write the report to (default $stdout)',
1151
- profile: 'optional - profile value consumed by #check'
1233
+ profile: 'optional - gate only this profile; core never requires hardware libraries'
1152
1234
  )
1153
1235
 
1154
- # Install OS packages + native gems for a capability profile.
1236
+ # Install OS packages and gems; hardware/full add HIDAPI and PC/SC, not a Python or executable YubiKey backend.
1155
1237
  #{self}.deps(
1156
1238
  profile: 'optional - one of PROFILES.keys (default :full)',
1157
1239
  yes: 'optional - non-interactive; assume yes to prompts (default false)',