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
@@ -17,7 +17,7 @@ module PWN
17
17
  basic_user = opts[:username]
18
18
  basic_pass = opts[:password]
19
19
  if (basic_user.nil? || basic_pass.nil?) && !opts[:host].to_s.empty?
20
- hit = Array(PWN::Plugins::Vault.offer(host: opts[:host], service: opts[:service], engagement: opts[:engagement] || opts[:engagement_id])).first
20
+ hit = Array(PWN::Plugins::Vault.offer(host: opts[:host], service: opts[:service], engagement: opts[:engagement] || opts[:engagement_id], yubikey: opts[:yubikey])).first
21
21
  if hit
22
22
  basic_user = hit[:username] if basic_user.nil?
23
23
  basic_pass = hit[:secret] if basic_pass.nil?
@@ -63,6 +63,7 @@ module PWN
63
63
  password: 'optional - password; omitted values are filled from Vault.offer',
64
64
  host: 'optional - hostname used to offer recon loot when username or password is omitted',
65
65
  service: 'optional - service name such as http or ssh used to match loot',
66
+ yubikey: 'optional - YubiKey selector Hash forwarded to hardware-protected Vault loot',
66
67
  engagement: 'optional - engagement id scoping the loot file (defaults to default)',
67
68
  engagement_id: 'optional - alias for engagement'
68
69
  )
@@ -397,25 +397,13 @@ module PWN
397
397
  dec_path = dec_path.to_s.strip
398
398
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
399
399
 
400
- unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
400
+ unless File.exist?(env_path)
401
401
  puts '[*] INFO: Google Workspace OAuth tokens updated in this session only; ' \
402
- "persistence to #{env_path} skipped (missing decryption artifacts)."
402
+ "persistence to #{env_path} skipped (missing vault)."
403
403
  return false
404
404
  end
405
405
 
406
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
407
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
408
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
409
- unless real_config_value?(value: key) && real_config_value?(value: iv)
410
- puts '[*] INFO: Google Workspace OAuth tokens updated in this session only; ' \
411
- 'decryptor has no usable key/iv.'
412
- return false
413
- end
414
-
415
- PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
416
- begin
417
- vault = YAML.load_file(env_path, symbolize_names: true)
418
- vault = {} unless vault.is_a?(Hash)
406
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |vault|
419
407
  vault[:plugins] = {} unless vault[:plugins].is_a?(Hash)
420
408
  vault[:plugins][:google_workspace] = {} unless vault[:plugins][:google_workspace].is_a?(Hash)
421
409
  slot = vault[:plugins][:google_workspace][:oauth]
@@ -423,15 +411,10 @@ module PWN
423
411
  %i[bearer_token refresh_token expires_at client_id client_secret redirect_uri scope].each do |k|
424
412
  slot[k] = oauth[k] if oauth.key?(k) && !oauth[k].nil?
425
413
  end
426
- yaml_env = YAML.dump(vault).gsub(/^(\s*):/, '\1')
427
- File.write(env_path, yaml_env)
428
- File.chmod(0o600, env_path)
429
- ensure
430
- PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
431
414
  end
432
415
  true
433
416
  rescue StandardError => e
434
- warn "[!] Google Workspace OAuth vault persistence failed (session tokens still updated): #{e.class}: #{e.message}"
417
+ warn "[!] Google Workspace OAuth vault persistence failed (session tokens still updated): #{e.class}"
435
418
  false
436
419
  end
437
420
 
@@ -26,7 +26,7 @@ module PWN
26
26
 
27
27
  root = File.expand_path(JOBS_DIR)
28
28
  FileUtils.mkdir_p(root, mode: 0o700)
29
- File.chmod(0o700, root)
29
+ File.chmod(0o700, root) unless (File.stat(root).mode & 0o777) == 0o700
30
30
  runtime = Float(opts[:max_runtime] || 0)
31
31
  raise ArgumentError, 'max_runtime must be finite and nonnegative' unless runtime.finite? && runtime >= 0
32
32
 
@@ -27,7 +27,7 @@ module PWN
27
27
  PWN::AI::Agent::Loop
28
28
  PWN::AI::Agent::Dispatch
29
29
  ].freeze
30
- SECRET_KEY_RX = /password|passwd|secret|token|api[_-]?key|authorization|bearer|cookie|session[_-]?id|private[_-]?key|decryptor|credential|ssh[_-]?key|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|vault|csrf/i
30
+ SECRET_KEY_RX = Regexp.union(PWN::Redaction::MASKED_FIELD, /password|passwd|secret|token|api[_-]?key|authorization|bearer|cookie|session[_-]?id|private[_-]?key|decryptor|credential|ssh[_-]?key|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|vault|csrf/i)
31
31
  SECRET_VALUE_RX = %r{
32
32
  -----BEGIN\ [A-Z ]*PRIVATE\ KEY----- |
33
33
  Bearer\s+[A-Za-z0-9\-._~+/]+=* |
@@ -644,11 +644,13 @@ module PWN
644
644
  private_class_method def self.inspect_debug(opts = {})
645
645
  val = opts[:value]
646
646
  key = opts[:key]
647
+ return PWN::Redaction::CONSTANT_MASK if key.to_s.match?(PWN::Redaction::MASKED_FIELD)
647
648
  return PWN::Redaction.token(kind: 'credential', value: val.to_s) if secret_key?(key: key)
648
649
  return PWN::Redaction.token(kind: 'opaque', value: val) if opaque_secret?(key: key, value: val)
649
650
  return format_debug_hash(hash: val) if val.is_a?(Hash)
650
651
  return sanitize_debug_text(text: val) if val.is_a?(String) && val.match?(SECRET_VALUE_RX)
651
652
 
653
+ val = PWN::Redaction.redact(value: val) if val.is_a?(Array)
652
654
  raw = sanitize_debug_text(text: val.inspect)
653
655
  raw = "#{raw[0, DEBUG_VALUE_MAX]}…" if raw.length > DEBUG_VALUE_MAX
654
656
  raw
@@ -6,9 +6,9 @@ require 'time'
6
6
 
7
7
  module PWN
8
8
  module Plugins
9
- # Plugin/binary/capability preflight. Plugins declare required_bins /
10
- # required_caps; HOST summaries list degraded modules without autoloading
11
- # the whole plugin tree.
9
+ # Plugin/binary/capability preflight. Native probes load host libraries
10
+ # only: no device enumeration, authentication or I/O. HOST summaries list
11
+ # degraded modules without autoloading the whole plugin tree.
12
12
  module PreflightChecker
13
13
  class MissingBinary < StandardError; end
14
14
  class MissingCapability < StandardError; end
@@ -32,6 +32,13 @@ module PWN
32
32
  'PWN::Plugins::Semgrep' => { bins: %w[semgrep] },
33
33
  'PWN::Plugins::ExploitDB' => { bins: %w[searchsploit] },
34
34
  'PWN::Plugins::Recon' => { bins: %w[subfinder httpx] },
35
+ 'PWN::Plugins::YubiKey' => {
36
+ bins: [],
37
+ native: [
38
+ { name: 'FIDO native transport', probe: 'PWN::Plugins::YubiKey', options: { application: :fido } },
39
+ { name: 'PC/SC native transport', probe: 'PWN::Plugins::YubiKey', options: { application: :oath } }
40
+ ]
41
+ },
35
42
  'PWN::Plugins::CredentialAttack' => { bins: %w[hydra john hashcat] }
36
43
  }.freeze
37
44
 
@@ -97,6 +104,11 @@ module PWN
97
104
  PLUGIN_DEPS.each do |mod, dep|
98
105
  missing = Array(dep[:bins]).reject { |b| bin?(name: b) }
99
106
  caps[mod] = { bins: dep[:bins], missing: missing, ok: missing.empty?, caps: Array(dep[:caps]) }
107
+ next unless dep[:native]
108
+
109
+ missing_native = Array(dep[:native]).reject { |probe| native_available?(probe: probe) }.map { |probe| probe[:name] }
110
+ caps[mod][:missing_native] = missing_native
111
+ caps[mod][:ok] &&= missing_native.empty?
100
112
  end
101
113
  row = {
102
114
  at: Time.now.utc.iso8601,
@@ -164,21 +176,25 @@ module PWN
164
176
  bins = Array(dep[:bins])
165
177
  caps = Array(dep[:caps])
166
178
  services = Array(dep[:services])
179
+ native = Array(dep[:native])
167
180
  missing_bins = bins.reject { |b| bin?(name: b) }
181
+ missing_native = native.reject { |probe| native_available?(probe: probe) }.map { |probe| probe[:name] }
168
182
  missing_caps = []
169
183
  if caps.include?('CAP_NET_RAW')
170
184
  pkt_ok = defined?(PWN::Plugins::Packet) && PWN::Plugins::Packet.respond_to?(:health) && PWN::Plugins::Packet.health[:ok]
171
185
  missing_caps << 'CAP_NET_RAW' unless pkt_ok || cap_net_raw?
172
186
  end
173
187
  missing_services = services.reject { |s| service?(name: s[:name] || s['name'], path: s[:path] || s['path']) }.map { |s| s[:name] || s['name'] }
174
- status = if missing_bins.empty? && missing_caps.empty? && missing_services.empty?
188
+ status = if missing_bins.empty? && missing_caps.empty? && missing_services.empty? && missing_native.empty?
175
189
  :ok
176
- elsif bins.any? && missing_bins.length == bins.length
190
+ elsif (bins.any? && missing_bins.length == bins.length) || (native.any? && missing_native.length == native.length)
177
191
  :dead
178
192
  else
179
193
  :degraded
180
194
  end
181
- { plugin: plugin, status: status, missing_bins: missing_bins, missing_caps: missing_caps, missing_services: missing_services }
195
+ row = { plugin: plugin, status: status, missing_bins: missing_bins, missing_caps: missing_caps, missing_services: missing_services }
196
+ row[:missing_native] = missing_native if dep[:native]
197
+ row
182
198
  end
183
199
  end
184
200
 
@@ -189,7 +205,7 @@ module PWN
189
205
  degraded = rows.select { |r| r[:status] == :degraded }
190
206
  lines = ["HOST plugins ok=#{rows.count { |r| r[:status] == :ok }} degraded=#{degraded.length} dead=#{dead.length}"]
191
207
  (degraded + dead).first(cap).each do |r|
192
- miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services])).join(',')
208
+ miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services]) + Array(r[:missing_native])).join(',')
193
209
  fb = Array(FALLBACKS[r[:plugin]]).join(',')
194
210
  extra = fb.empty? ? '' : " fallback=#{fb}"
195
211
  anchor = r[:plugin].to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
@@ -198,6 +214,15 @@ module PWN
198
214
  lines.join("\n")
199
215
  end
200
216
 
217
+ # Only trusted declarations above may supply a host-only available? probe.
218
+ private_class_method def self.native_available?(opts = {})
219
+ probe = opts[:probe]
220
+ provider = probe.fetch(:probe).split('::').inject(Object) { |scope, name| scope.const_get(name, false) }
221
+ provider.available?(probe.fetch(:options, {})) == true
222
+ rescue LoadError, StandardError
223
+ false
224
+ end
225
+
201
226
  public_class_method def self.authors
202
227
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
203
228
  end
@@ -247,7 +272,7 @@ module PWN
247
272
  path: 'required - unix socket path (e.g. /var/run/docker.sock)'
248
273
  )
249
274
 
250
- # Run check and return its result
275
+ # Report dependencies; native rows add missing_native from host-only library probes, never device readiness.
251
276
  #{self}.check(
252
277
  names: 'optional - Array names value consumed by #check'
253
278
  )
@@ -258,7 +283,7 @@ module PWN
258
283
  limit: 'optional - limit value consumed by #host_summary'
259
284
  )
260
285
 
261
- # Refresh ~/.pwn/capabilities.json with bin/CAP_* presence.
286
+ # Refresh ~/.pwn/capabilities.json with bin/CAP_* and host-native-library presence; no device discovery.
262
287
  #{self}.manifest(
263
288
  refresh: 'optional - unused reserved refresh flag'
264
289
  )
@@ -138,7 +138,7 @@ module PWN
138
138
  File.rename(temp, path)
139
139
  end
140
140
  result = JSON.parse(File.read(path), symbolize_names: true).merge(path: path)
141
- result[:loot] = harvest_loot(assets: result[:assets], engagement_id: engagement)
141
+ result[:loot] = harvest_loot(assets: result[:assets], engagement_id: engagement, yubikey: opts[:yubikey])
142
142
  if opts[:ingest]
143
143
  begin
144
144
  ingestor = opts[:ingestor] || PWN::AI::Context.method(:ingest)
@@ -285,6 +285,7 @@ module PWN
285
285
  source: 'recon',
286
286
  where: opts[:where] || "banner:#{obs[:port] || asset[:port]}",
287
287
  engagement: eng,
288
+ yubikey: opts[:yubikey],
288
289
  finding_id: opts[:finding_id]
289
290
  )
290
291
  end
@@ -354,6 +355,7 @@ module PWN
354
355
  modules: 'optional - Array nmap/banner/tls/subfinder/nuclei; defaults nmap/banner',
355
356
  ports: 'optional - Array of integer ports; defaults 80 and 443',
356
357
  engagement_id: 'optional - simple identifier; defaults default',
358
+ yubikey: 'optional - hardware selector Hash for harvested loot; device failure raises without software fallback',
357
359
  root: 'optional - engagements directory; defaults ~/.pwn/engagements',
358
360
  timeout: 'optional - per-operation timeout seconds; defaults 15, maximum 300',
359
361
  ingest: 'optional - invoke Context.ingest after persistence; defaults false',
@@ -366,6 +368,7 @@ module PWN
366
368
  assets: 'required - Array of recon asset hashes with address and observations',
367
369
  engagement_id: 'optional - engagement id scoping the loot file (defaults to default)',
368
370
  engagement: 'optional - alias for engagement_id',
371
+ yubikey: 'optional - hardware selector Hash forwarded to Vault.ingest for captured secrets',
369
372
  where: 'optional - provenance path overlay when observation port is missing',
370
373
  finding_id: 'optional - finding id linked onto harvested secrets'
371
374
  )
@@ -278,9 +278,294 @@ module PWN
278
278
  # 2. '/' anywhere else → :path (host-native file nav)
279
279
  # 3. else → :ruby (same Pry::InputCompleter menu as the pwn REPL)
280
280
  class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
281
- # Foreground Loop plus one scoped terminal reader, not another Pry
282
- # readline and not a worker thread running tools without their locals.
281
+ # The terminal owner survives CTRL+C; only its mission tree is killed.
283
282
  def pwn_ai_run_steerable(opts = {})
283
+ pwn_ai_run_mission(opts.merge(operation: :steerable))
284
+ end
285
+
286
+ # Both native tools and regex-ReAct must execute below the same owner.
287
+ # Never retry a failed mission in the terminal/controller process.
288
+ def pwn_ai_run_legacy(opts = {})
289
+ result = pwn_ai_run_mission(opts.merge(operation: :legacy))
290
+ PWN::Env[:ai][result.fetch(:engine).to_sym][:response_history] = result[:response_history]
291
+ rescue StandardError => e
292
+ warn "[pwn-ai] legacy agent mission failed (#{e.class}: #{e.message.to_s.split("\n").first}); no uncontained retry was attempted"
293
+ ensure
294
+ opts[:request]&.replace('nil')
295
+ PWN::Plugins::REPL.ready_tty!
296
+ end
297
+
298
+ def pwn_ai_run_mission(opts = {})
299
+ require 'io/console'
300
+ input = opts[:input] || $stdin
301
+ reader, writer = IO.pipe
302
+ run = proc do
303
+ config = %i[pwn_ai_session_id pwn_ai_debug pwn_ai_trace pwn_ai_speak pwn_ai_agent].to_h { |key| [key, opts[:pry]&.config&.public_send(key)] }
304
+ descriptor = { operation: opts.fetch(:operation), config: config, options: opts.except(:pry, :input, :on_tool, :steering, :debug_tee) }
305
+ mission = PWN::Plugins::AISandbox::MissionProcess.new(descriptor: descriptor, fds: { 4 => writer, 0 => input })
306
+ writer.close
307
+ envelope = {}
308
+ reader.each_line do |line|
309
+ frame = JSON.parse(line, symbolize_names: true)
310
+ if frame[:event] == 'tool'
311
+ opts[:on_tool]&.call(*frame[:args])
312
+ else
313
+ envelope = frame
314
+ end
315
+ end
316
+ raise IOError, envelope[:error] if envelope[:error]
317
+
318
+ envelope[:result]
319
+ ensure
320
+ mission&.close
321
+ end
322
+ input.tty? ? input.cooked(&run) : run.call
323
+ ensure
324
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
325
+ end
326
+
327
+ def pwn_ai_mission_entry(opts = {})
328
+ pry = Pry.new
329
+ opts.fetch(:config).each { |key, value| pry.config.public_send("#{key}=", value) }
330
+ writer = PWN::AI::Agent::MissionNamespace.io(4)
331
+ options = { trace: pry.config.pwn_ai_trace }.merge(opts.fetch(:options)).merge(
332
+ pry: pry, input: PWN::AI::Agent::MissionNamespace.io(0),
333
+ on_tool: ->(*args) { writer.puts(JSON.generate(event: 'tool', args: args)) }
334
+ )
335
+ begin
336
+ result = opts[:operation] == :legacy ? pwn_ai_run_legacy_child(options) : pwn_ai_run_steerable_child(options)
337
+ writer.puts(JSON.generate(result: result))
338
+ rescue StandardError => e
339
+ writer.puts(JSON.generate(error: "#{e.class}: #{e.message}"))
340
+ ensure
341
+ writer.close
342
+ end
343
+ end
344
+
345
+ def pwn_ai_run_legacy_child(opts = {})
346
+ request = opts[:request]
347
+ orig_request = opts[:original_request]
348
+ pi = opts[:pry]
349
+ # Do NOT rebind the 'request' parameter (the string object passed by Pry's after_read hook).
350
+ # We will mutate it to 'nil' at the end of handling so Pry does not eval the natural-language
351
+ # prompt text as Ruby (which was causing noisy exceptions *after* the green AI response print).
352
+ debug = pi.config.pwn_ai_debug
353
+ engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
354
+ response_history = PWN::Env[:ai][engine][:response_history]
355
+ speak_answer = pi.config.pwn_ai_speak
356
+ is_agent = (pi.config.pwn_ai_agent == true)
357
+
358
+ # pwn-ai agent mode: load skills context for autonomous task carrying
359
+ skills_context = ''
360
+ PWN::Skills.each { |n, m| skills_context += "\n--- SKILL #{n} ---\n#{m[:content].to_s[0, 1200]}\n" } if is_agent && PWN.const_defined?(:Skills) && PWN::Skills.is_a?(Hash)
361
+
362
+ memory_context = ''
363
+ memory_context = PWN::Memory.to_context(limit: 25) if is_agent && PWN.const_defined?(:Memory)
364
+
365
+ sess_id = begin
366
+ pi.config.pwn_ai_session_id
367
+ rescue StandardError
368
+ nil
369
+ end
370
+
371
+ # Pre-process for clear CLI execution intent (e.g. "what does `id` return?")
372
+ # This makes the agent actually *run* commands instead of just explaining them.
373
+ curr_req = orig_request.chomp
374
+ if is_agent && sess_id && PWN.const_defined?(:Sessions)
375
+ begin
376
+ PWN::Sessions.append(session_id: sess_id, role: 'user', content: orig_request)
377
+ rescue StandardError
378
+ nil
379
+ end
380
+ end
381
+ if is_agent && request =~ /`([^`]+)`/
382
+ potential = ::Regexp.last_match(1).strip
383
+ # Looks like a shell command (not PWN ruby)
384
+ unless potential =~ /^(PWN::|def |class |require |puts |pp )/
385
+ curr_req = "The user wants the *actual raw output* of this command (do not just describe it): `#{potential}`. " \
386
+ 'To fulfill the request accurately, you MUST immediately output ONLY a bash code block with the exact command. ' \
387
+ "Example format: ```bash\n#{potential}\n``` . After the host executes it, you will receive the OBSERVATION with the real output."
388
+ end
389
+ end
390
+
391
+ # Strict system prompt for agent mode (forces tool use over explanation)
392
+ system_role = nil
393
+ if is_agent
394
+ base = PWN::Env[:ai][engine][:system_role_content] || 'You are an ethical hacker.'
395
+ system_role = base + <<~PROMPT
396
+
397
+ You are operating as an autonomous agent inside the PWN REPL driver.
398
+
399
+ PRIMARY RULE FOR CLI AND TOOLS: When the user asks for the output of a command, "what does X return?", "run X", or anything that requires real execution, you MUST use a tool call.#{' '}
400
+ NEVER just explain what a command does or what its output "would be".#{' '}
401
+ To execute anything:
402
+ - Output *exactly and only* a fenced code block.
403
+ - For shell/CLI: ```bash
404
+ <exact command here>
405
+ ```
406
+ - For PWN Ruby modules: ```ruby
407
+ PWN::Plugins::NmapIt.port_scan(...)
408
+ ```
409
+ The host will execute it (Ruby in full PWN context, bash via shell) and reply with an OBSERVATION containing the real result.#{' '}
410
+ Then continue or give the final answer.
411
+
412
+ Available tools include all PWN::Plugins (NmapIt, TransparentBrowser, etc.), SAST, Reports, and any CLI via bash blocks.
413
+ Skills available this session:#{skills_context}
414
+ #{memory_context}
415
+
416
+ PERSISTENT CAPABILITIES (use via ruby code blocks or direct calls):
417
+ - Memory (cross-session): PWN::Memory.remember(key: :key, value: val, category: :fact|:preference|:lesson)
418
+ PWN::Memory.recall(query: 'foo'), PWN::Memory.forget(key: key)
419
+ - Sessions: current session id = #{sess_id}; PWN::Sessions.append(session_id: '#{sess_id}', role: 'observation', content: obs)
420
+ - Cron: PWN::Cron.create(schedule: '0 * * * *', prompt: 'task here', name: 'foo')
421
+ PWN::Cron.run(id: 'id'); list with PWN::Cron.list
422
+ - Agents/Delegation: PWN::AI::Agent::SAST.analyze(request: ...); PWN::AI::Agent::VulnGen etc.
423
+ For sub-agents use threads or separate eval calls and feed results back as OBS.
424
+
425
+ After receiving an observation, decide the next step or conclude.
426
+ If you output text without a code block, it will be treated as your final answer to the user.
427
+ PROMPT
428
+ end
429
+
430
+ max_turns = is_agent ? 7 : 1
431
+ turn = 0
432
+ last_response = ''
433
+ tool_was_executed_this_turn = false
434
+
435
+ while turn < max_turns
436
+ chat_opts = {
437
+ request: curr_req,
438
+ response_history: response_history,
439
+ speak_answer: speak_answer,
440
+ spinner: false
441
+ }
442
+ chat_opts[:system_role_content] = system_role if system_role
443
+
444
+ case engine
445
+ when :anthropic
446
+ response = PWN::AI::Anthropic.chat(chat_opts)
447
+ when :gemini
448
+ response = PWN::AI::Gemini.chat(chat_opts)
449
+ when :grok
450
+ response = PWN::AI::Grok.chat(chat_opts)
451
+ when :ollama
452
+ response = PWN::AI::Ollama.chat(chat_opts)
453
+ when :openai
454
+ response = PWN::AI::OpenAI.chat(chat_opts)
455
+ when :openwebui
456
+ response = PWN::AI::OpenWebUI.chat(chat_opts)
457
+ else
458
+ raise "ERROR: Unsupported AI Engine: #{engine}"
459
+ end
460
+
461
+ if response.nil?
462
+ last_response = 'Model not currently supported with API key.'
463
+ else
464
+ if response[:choices].last.keys.include?(:text)
465
+ last_response = response[:choices].last[:text].to_s
466
+ else
467
+ last_response = response[:choices].last[:content].to_s
468
+ end
469
+ response_history = {
470
+ id: response[:id],
471
+ object: response[:object],
472
+ model: response[:model],
473
+ usage: response[:usage]
474
+ }
475
+ response_history[:choices] ||= response[:choices]
476
+ end
477
+
478
+ puts "\n\001\e[32m\002#{last_response}\001\e[0m\002\n\n"
479
+ if is_agent && sess_id && PWN.const_defined?(:Sessions)
480
+ begin
481
+ PWN::Sessions.append(session_id: sess_id, role: 'assistant', content: last_response)
482
+ rescue StandardError
483
+ nil
484
+ end
485
+ end
486
+
487
+ if debug
488
+ puts 'DEBUG: response_history => '
489
+ pp response_history
490
+ end
491
+ PWN::Env[:ai][engine][:response_history] = response_history
492
+
493
+ # === Agent tool execution: parse code blocks from *this* response and actually run them ===
494
+ tool_was_executed_this_turn = false
495
+ if is_agent
496
+ # Robust regex: tolerate language specifier, extra whitespace, and text around the block
497
+ last_response.scan(/```(?:\s*(ruby|bash|sh|shell|zsh))?\s*\n?(.*?)\n?```/m).each do |lang, code|
498
+ code = code.strip
499
+ next if code.empty? || tool_was_executed_this_turn
500
+
501
+ lang = (lang || 'bash').downcase
502
+ puts "\001\e[33m\002[ pwn-ai AGENT EXEC #{lang} ]\e[0m\002 #{code[0..90]}..."
503
+
504
+ obs = ''
505
+ begin
506
+ obs = PWN::Plugins::AISandbox.protect_call do
507
+ if lang == 'ruby'
508
+ require 'stringio'
509
+ old_stdout = $stdout
510
+ $stdout = StringIO.new
511
+ res = eval(code, TOPLEVEL_BINDING) # rubocop:disable Security/Eval -- intentional sandboxed legacy tool evaluation
512
+ captured = $stdout.string
513
+ $stdout = old_stdout
514
+ (captured + "\n=> #{res.inspect}").strip
515
+ else
516
+ require 'open3'
517
+ stdout, stderr, status = Open3.capture3(code)
518
+ output = stdout
519
+ output += "\n[stderr]\n#{stderr}" unless stderr.to_s.strip.empty?
520
+ output += "\n[exit: #{status.exitstatus}]" unless status.success?
521
+ output.strip
522
+ end
523
+ end
524
+ obs = JSON.generate(obs) unless obs.is_a?(String)
525
+ rescue StandardError => e
526
+ obs = "ERROR executing #{lang} block: #{e.class} - #{e.message}"
527
+ end
528
+
529
+ puts "\001\e[36m\002[OBSERVATION from #{lang}]\001\e[0m\002\n#{obs[0..700]}\n"
530
+ if is_agent && sess_id && PWN.const_defined?(:Sessions)
531
+ begin
532
+ PWN::Sessions.append(session_id: sess_id, role: 'observation', content: obs)
533
+ rescue StandardError
534
+ nil
535
+ end
536
+ end
537
+
538
+ # Feed real result back to the model as the next "user" message in the loop
539
+ curr_req = "OBSERVATION (#{lang} execution result for previous block):\n#{obs}\n\n" \
540
+ "Now continue fulfilling the original user request: #{orig_request}. " \
541
+ 'If the task is complete, give the final answer (no more code blocks). Otherwise output the next needed tool block.'
542
+
543
+ tool_was_executed_this_turn = true
544
+ turn += 1
545
+ break # one execution per model turn for controlled pacing
546
+ end
547
+ end
548
+
549
+ # If we executed something, loop to let the model react to the OBS
550
+ next if tool_was_executed_this_turn
551
+
552
+ # No tool executed this turn -> this last_response is the final answer
553
+ break
554
+ end
555
+
556
+ # If in agent mode and the model never produced an executable block but the query clearly wanted execution,
557
+ # give one last chance with a strong reminder (helps weaker models like some Ollama ones)
558
+ if is_agent && !tool_was_executed_this_turn && orig_request =~ /`[^`]+`/ && turn < max_turns
559
+ reminder = 'The user explicitly asked about the output of a command in backticks. ' \
560
+ 'Do not describe the command. Output *only* the corresponding ```bash block now so the host can run it and give you the real result.'
561
+ curr_req = "#{reminder}\nOriginal: #{orig_request}"
562
+ # One final direct call (no full re-loop to avoid complexity)
563
+ # (The main loop already handled most cases; this is a safety net)
564
+ end
565
+ { engine: engine, response_history: response_history }
566
+ end
567
+
568
+ def pwn_ai_run_steerable_child(opts = {})
284
569
  previous = Thread.current[:pwn_steering_input]
285
570
  workspace = File.realpath(Dir.pwd)
286
571
  lock_path = File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock")
@@ -862,24 +1147,11 @@ module PWN
862
1147
  env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
863
1148
  dec_path = dec_path.to_s.strip
864
1149
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
865
- return false unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
866
-
867
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
868
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
869
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
870
- return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
871
-
872
- # Never decrypt the live vault in place: failed encryption must leave
873
- # the previous ciphertext intact. Rename only the encrypted merge.
874
- require 'tempfile'
875
- Tempfile.create(['.pwn-ai-selection', '.yaml'], File.dirname(env_path)) do |file|
876
- file.binmode
877
- file.write(File.binread(env_path))
878
- file.flush
879
- path = file.path
880
- PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
881
- cfg = YAML.load_file(path, symbolize_names: true)
882
- cfg = {} unless cfg.is_a?(Hash)
1150
+ return false unless File.exist?(env_path)
1151
+
1152
+ # Shared update preserves hardware or legacy format and publishes only
1153
+ # ciphertext after a locked re-read and final conflict comparison.
1154
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
883
1155
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
884
1156
  cfg[:ai][:active] = engine unless opts.key?(:system_role_content)
885
1157
  unless model.to_s.strip.empty?
@@ -895,15 +1167,10 @@ module PWN
895
1167
  cfg[:ai][engine.to_sym] ||= {}
896
1168
  cfg[:ai][engine.to_sym][:system_role_content] = opts[:system_role_content]
897
1169
  end
898
- yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
899
- File.write(path, yaml_env)
900
- File.chmod(0o600, path)
901
- PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
902
- File.rename(path, env_path)
903
1170
  end
904
1171
  true
905
1172
  rescue StandardError => e
906
- warn "[pwn-ai] settings persist failed: #{e.class}: #{e.message}"
1173
+ warn "[pwn-ai] settings persist failed: #{e.class}; check vault access and retry."
907
1174
  false
908
1175
  end
909
1176