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
@@ -49,7 +49,8 @@ module PWN
49
49
 
50
50
  ENVIRONMENT
51
51
  host : #{host_line}
52
- cwd : #{Dir.pwd}
52
+ cwd : #{Thread.current[:pwn_request_runtime]&.contract&.dig(:cwd) || PWN::Plugins::AISandbox.task_directory}
53
+ writes : new files go to #{PWN::Plugins::AISandbox.workspace} unless the original operator request names another path
53
54
  ruby : #{RUBY_VERSION}
54
55
  pwn : #{pwn_version}
55
56
  session_id : #{session_id || '(none)'}
@@ -77,7 +78,8 @@ module PWN
77
78
 
78
79
  ENVIRONMENT
79
80
  host : #{host_line}
80
- cwd : #{Dir.pwd}
81
+ cwd : #{Thread.current[:pwn_request_runtime]&.contract&.dig(:cwd) || PWN::Plugins::AISandbox.task_directory}
82
+ writes : new files go to #{PWN::Plugins::AISandbox.workspace} unless the original operator request names another path
81
83
  ruby : #{RUBY_VERSION}
82
84
  pwn : #{pwn_version}
83
85
  session_id : #{session_id || '(none)'}
@@ -100,6 +102,9 @@ module PWN
100
102
  resolved mistake or a durable procedure change, `skills_update`
101
103
  folds that RL note into the matching skill.
102
104
  TransparentBrowser: open once, reuse browser_obj, close once.
105
+ If Open WebUI lists a Jev model, browser_act uses Jev for the
106
+ click/type/select decision and the active model only for field text.
107
+ #{workspace_prompt(engine: engine)}
103
108
 
104
109
  HOST LOAD
105
110
  #{host_load_block}
@@ -195,6 +200,12 @@ module PWN
195
200
  ''
196
201
  end
197
202
 
203
+ private_class_method def self.workspace_prompt(opts = {})
204
+ return '' unless opts[:engine].to_s == 'openwebui'
205
+
206
+ "Open WebUI workspace tools, including Ask Jev when it is installed, run on the server through workspace_tool. Do not put them in the client tools array.\n"
207
+ end
208
+
198
209
  private_class_method def self.host_line
199
210
  `uname -srm 2>/dev/null`.strip
200
211
  rescue StandardError
@@ -24,7 +24,7 @@ module PWN
24
24
  @request_id = (opts[:request_id] || SecureRandom.uuid).to_s.freeze
25
25
  @original_request = PWN::AI::Agent::Result.utf8_text(text: opts[:request]).freeze
26
26
  @request = @original_request
27
- @cwd = Dir.pwd
27
+ @cwd = Thread.current[:pwn_solve_workspace] || PWN::Plugins::AISandbox.task_directory
28
28
  @amendments = [].freeze
29
29
  @observations = {}
30
30
  @events = []
@@ -87,10 +87,50 @@ module PWN
87
87
  mode == 'cancel' ? finish!(status: 'cancelled') : checkpoint!
88
88
  end
89
89
 
90
+ # Private in-memory exec handoff. Locks and cancellation callbacks belong
91
+ # to the caller; the enclosing namespace is the child's cancellation owner.
92
+ def mission_snapshot
93
+ @mutex.synchronize do
94
+ state = instance_variables.reject { |key| %i[@mutex @cancel @memory @router].include?(key) }.to_h { |key| [key, instance_variable_get(key)] }
95
+ state[:memory] = @memory.instance_variables.to_h { |key| [key, @memory.instance_variable_get(key)] }
96
+ state[:router] = @router.instance_variables.to_h { |key| [key, @router.instance_variable_get(key)] }
97
+ state
98
+ end
99
+ end
100
+
101
+ private_class_method def self.from_mission_snapshot(opts = {})
102
+ state = opts.fetch(:state).dup
103
+ runtime = allocate
104
+ { memory: EngagementMemory, router: Profiles }.each do |key, klass|
105
+ object = klass.allocate
106
+ state.delete(key).each { |name, value| object.instance_variable_set(name, value) }
107
+ runtime.instance_variable_set(:"@#{key}", object)
108
+ end
109
+ state.each { |name, value| runtime.instance_variable_set(name, value) }
110
+ runtime.instance_variable_set(:@mutex, Mutex.new)
111
+ runtime.worker_view!
112
+ runtime
113
+ end
114
+
115
+ # A fork gets routing and child-loop ownership, but must never persist
116
+ # the parent's checkpoint generation. The parent owns final receipts.
117
+ def worker_view!
118
+ @worker_copy = true
119
+ @trace = false
120
+ end
121
+
122
+ def worker_provenance!(opts = {})
123
+ @worker_provenance = opts.fetch(:rows).to_h.transform_keys(&:to_s)
124
+ end
125
+
90
126
  # Only host observations enter this store, never parsed tool stdout.
91
127
  def observe!(opts = {})
92
128
  @mutex.synchronize do
93
- opts.fetch(:observations).each { |path, row| @observations[path.to_s] = row }
129
+ opts.fetch(:observations).each do |path, row|
130
+ child = @worker_provenance&.dig(path.to_s)
131
+ row = row.merge(child.slice(:child_request_id, :child_turn_id, :child_provenance)) if child && child[:artifact_turn_id] == @contract[:id] && child[:sha256] == row[:sha256] && child[:stat] == row[:stat] && child[:child_request_id]
132
+ @observations[path.to_s] = row
133
+ end
94
134
  checkpoint!
95
135
  end
96
136
  end
@@ -295,8 +335,38 @@ module PWN
295
335
  observed >= @started_at && observed <= Time.now && (!row[:max_age] || Time.now - observed <= row[:max_age].to_f)
296
336
  end
297
337
 
338
+ # Uncertainty is a recovery state, not another instruction to rewrite
339
+ # artifacts forever. Retain the replay fence and return actionable IDs.
340
+ def blocker
341
+ last = events.reverse.find { |row| row[:phase] == 'end' }
342
+ return "Tool execution blocked before handler start: #{last[:diagnostic]}. Enable the required kernel protection or choose a supported host; no unsafe fallback was used." if last && last[:state] == 'rejected'
343
+
344
+ rows = blocking_effects
345
+ return nil if rows.empty?
346
+
347
+ details = rows.map { |row| "#{row[:tool]} action=#{row[:action_id]} payload_sha256=#{row[:payload_sha256]}" }
348
+ "Uncertain side effect: #{details.join('; ')}. Do not replay or rewrite blindly. " \
349
+ 'Inspect the exact affected target and request operator-approved reconciliation; unrelated reads or new artifacts cannot resolve this action.'
350
+ end
351
+
298
352
  def unmet
299
- @mutex.synchronize { @effects.value?('uncertain') ? ['uncertain_side_effect'] : [] }
353
+ blocking_effects.any? ? ['uncertain_side_effect'] : []
354
+ end
355
+
356
+ # A finished search is an observation. Only a write, a store, or a
357
+ # named remote target can block the answer. Replay of an interrupted
358
+ # command stays denied even when it does not block.
359
+ def blocking_effects
360
+ @mutex.synchronize do
361
+ @effects.select { |_key, state| state == 'uncertain' }.keys.filter_map do |key|
362
+ row = @events.reverse.find { |item| item[:payload_sha256] == key && item[:phase] == 'start' }
363
+ row if row && durable_effect?(effect: row[:effect], target: row[:target])
364
+ end
365
+ end
366
+ end
367
+
368
+ def durable_effect?(opts = {})
369
+ %w[write store].include?(opts[:effect].to_s) || !opts[:target].to_s.empty?
300
370
  end
301
371
 
302
372
  # A started mutation stays uncertain across interruption or process death.
@@ -320,15 +390,33 @@ module PWN
320
390
  end
321
391
  return { success: false, error: 'uncertain_side_effect', hint: 'Inspect the target before any operator-approved recovery; this mutation was not replayed.' } if denied
322
392
 
393
+ before = TurnFinalizer.artifact_snapshot(paths: @contract[:required_artifacts])
323
394
  begin
324
395
  result = provider_attempt(&)
325
- failed = result.is_a?(Hash) && (result[:error] || result['error'] || result[:success] == false || result['success'] == false)
326
- state = if failed
396
+ failed = result.is_a?(Hash) && (result[:error] || result['error'] || result[:success] == false || result['success'] == false || result[:ok] == false || (result.key?(:exit) && result[:exit] != 0))
397
+ timed_out = result.is_a?(Hash) && (result[:error] || result['error']).to_s.match?(/timeout/i)
398
+ # A returned timeout still may have applied. Only a finished
399
+ # command (exit/stdout, no timeout) is an observation.
400
+ observed = result.is_a?(Hash) && !timed_out && (result.key?(:exit) || result.key?('exit') || result.key?(:stdout) || result.key?('stdout'))
401
+ unless failed
402
+ # Classification is only scheduling advice: successful arbitrary
403
+ # code may write files even when classified eval/read. The host
404
+ # delta, not payload keywords or stdout, proves the actual write.
405
+ TurnFinalizer.observe_artifacts(paths: @contract[:required_artifacts], before: before,
406
+ effect: :write, success: true, action_id: opts[:action_id], tool: opts[:name])
407
+ end
408
+ state = if !failed
409
+ 'completed'
410
+ elsif timed_out || !observed
327
411
  mutation ? 'uncertain' : 'failed'
328
412
  else
329
- 'completed'
413
+ 'failed'
330
414
  end
331
415
  result
416
+ rescue PWN::Plugins::AISandbox::NotStarted => e
417
+ state = 'rejected'
418
+ action[:diagnostic] = e.message
419
+ { success: false, error: e.message, execution_state: 'not_started' }
332
420
  ensure
333
421
  @mutex.synchronize do
334
422
  state ||= mutation ? 'uncertain' : 'interrupted'
@@ -474,18 +562,35 @@ module PWN
474
562
  end
475
563
 
476
564
  def checkpoint!
477
- FileUtils.mkdir_p(File.dirname(@checkpoint_path), mode: 0o700)
478
- File.open("#{@checkpoint_path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
479
- lock.flock(File::LOCK_EX)
480
- current = File.file?(@checkpoint_path) ? JSON.parse(File.read(@checkpoint_path)).fetch('generation', 0) : 0
481
- raise IOError, 'stale request checkpoint: reload before proceeding' unless current == @generation
482
-
483
- @generation += 1
484
- write_checkpoint!
565
+ return if @worker_copy
566
+
567
+ PWN::Plugins::AISandbox.control_directory(path: File.dirname(@checkpoint_path)) do |directory|
568
+ path = File.join(directory, File.basename(@checkpoint_path))
569
+ File.open("#{path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
570
+ raise IOError, 'checkpoint lock must be a singly linked regular file' unless lock.stat.file? && lock.stat.nlink == 1
571
+
572
+ lock.flock(File::LOCK_EX)
573
+ current = checkpoint_generation(path: path)
574
+ raise IOError, 'stale request checkpoint: reload before proceeding' unless current == @generation
575
+
576
+ @generation += 1
577
+ write_checkpoint!(path: path)
578
+ end
579
+ end
580
+ end
581
+
582
+ def checkpoint_generation(opts = {})
583
+ File.open(opts.fetch(:path), File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
584
+ raise IOError, 'checkpoint must be a singly linked regular file' unless file.stat.file? && file.stat.nlink == 1
585
+
586
+ JSON.parse(file.read).fetch('generation', 0)
485
587
  end
588
+ rescue Errno::ENOENT
589
+ 0
486
590
  end
487
591
 
488
- def write_checkpoint!
592
+ def write_checkpoint!(opts = {})
593
+ path = opts.fetch(:path)
489
594
  rows = @observations.transform_values do |row|
490
595
  row.merge(head: Base64.strict_encode64(row[:head]), tail: Base64.strict_encode64(row[:tail]))
491
596
  end
@@ -493,14 +598,13 @@ module PWN
493
598
  request: @request, amendments: @amendments, cwd: @cwd, started_at: @started_at.iso8601(9),
494
599
  observations: rows, outcome: @outcome, events: @events, effects: @effects, evidence: @evidence,
495
600
  enabled_toolsets: @enabled_toolsets, verification_contract: @verification_contract }
496
- FileUtils.mkdir_p(File.dirname(@checkpoint_path), mode: 0o700)
497
- temp = "#{@checkpoint_path}.#{SecureRandom.hex(8)}.tmp"
601
+ temp = "#{path}.#{SecureRandom.hex(8)}.tmp"
498
602
  File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
499
603
  file.write(JSON.generate(PWN::AI::Agent::Result.utf8_value(value: data)))
500
604
  file.flush
501
605
  file.fsync
502
606
  end
503
- File.rename(temp, @checkpoint_path)
607
+ File.rename(temp, path)
504
608
  ensure
505
609
  File.unlink(temp) if temp && File.exist?(temp)
506
610
  end
@@ -1079,10 +1079,12 @@ module PWN
1079
1079
  reasons[why] += 1
1080
1080
  end
1081
1081
  end
1082
+ bak = nil
1082
1083
  unless dry
1083
- bak = "#{path}.bak-p15-#{Time.now.utc.strftime('%Y%m%d%H%M%S')}"
1084
- FileUtils.cp(path, bak)
1085
- File.open(path, 'w') { |f| kept.each { |r| f.puts(JSON.generate(r)) } }
1084
+ PWN::Backups.transaction do
1085
+ bak = PWN::Backups.capture(path: path, label: 'preferences')
1086
+ File.open(path, 'w') { |f| kept.each { |r| f.puts(JSON.generate(r)) } }
1087
+ end
1086
1088
  end
1087
1089
  {
1088
1090
  before: raw.length,
@@ -85,6 +85,8 @@ module PWN
85
85
  end
86
86
 
87
87
  public_class_method def self.consolidate_file(opts = {})
88
+ return PWN::Backups.transaction { consolidate_file(opts) } if opts[:dry_run] == false && !PWN::Backups.active?
89
+
88
90
  path = File.expand_path(opts.fetch(:path))
89
91
  raise ArgumentError, 'symlink skills are not writable consolidation targets' if File.symlink?(path)
90
92
 
@@ -99,8 +101,7 @@ module PWN
99
101
  lock.flock(File::LOCK_EX)
100
102
  raise ArgumentError, 'skill changed during consolidation' unless File.read(path) == original && !File.symlink?(path)
101
103
 
102
- backup = "#{path}.#{digest}.bak"
103
- File.open(backup, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(original) } unless File.exist?(backup)
104
+ backup = PWN::Backups.capture(path: path, label: 'skill-consolidation')
104
105
  tmp = "#{path}.#{Process.pid}.tmp"
105
106
  File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, File.stat(path).mode & 0o777) do |file|
106
107
  file.write(result[:content])
@@ -80,6 +80,8 @@ module PWN
80
80
  # skills_root: 'optional - local skills root; default ~/.pwn/skills'
81
81
  # )
82
82
  public_class_method def self.decide(opts = {})
83
+ return PWN::Backups.transaction { decide(opts) } unless PWN::Backups.active?
84
+
83
85
  digest = opts[:source_sha256].to_s
84
86
  decision = opts[:decision].to_s
85
87
  root = File.realpath(opts[:skills_root] || File.join(Dir.home, '.pwn', 'skills'))
@@ -202,8 +204,7 @@ module PWN
202
204
  original = File.binread(path)
203
205
  raise 'skill source changed' unless Digest::SHA256.hexdigest(original) == proposal[:source_sha256]
204
206
 
205
- backup = "#{path}.#{proposal[:source_sha256]}.bak"
206
- File.open(backup, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) { |file| file.write(original) } unless File.exist?(backup)
207
+ backup = PWN::Backups.capture(path: path, label: 'skill-review')
207
208
  raise 'backup changed' unless !File.symlink?(backup) && File.binread(backup) == original
208
209
 
209
210
  content = "#{original}\n\n## Reviewed procedure\n\n#{procedure[:text]}\n"
@@ -330,14 +331,15 @@ module PWN
330
331
  end
331
332
 
332
333
  private_class_method def self.apply_update(opts = {})
334
+ lock = Learning.send(:learning_lock)
335
+ return PWN::Backups.transaction { apply_update(opts) } unless PWN::Backups.active?
336
+
333
337
  proposal = opts[:proposal]
334
338
  path = proposal[:path].to_s
335
339
  return finish(proposal.merge(action: 'refused', reason: 'skill path missing')) unless File.file?(path) && !File.symlink?(path)
336
340
 
337
341
  original = File.read(path)
338
- digest = Digest::SHA256.hexdigest(original)
339
- backup = "#{path}.#{digest}.bak"
340
- File.open(backup, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(original) } unless File.exist?(backup)
342
+ backup = PWN::Backups.capture(path: path, label: 'skill-review')
341
343
  signature = (opts[:evidence] || {})[:signature].to_s
342
344
  lesson = "#{signature}\n#{opts[:procedure][:text]}"
343
345
  written = Learning.update_skill(name: proposal[:name], lesson: lesson, query: proposal[:request], pwn_skills_path: opts[:skills_root])
@@ -349,6 +351,8 @@ module PWN
349
351
 
350
352
  PWN::Config.load_skills(pwn_skills_path: opts[:skills_root]) if defined?(PWN::Config) && PWN::Config.respond_to?(:load_skills)
351
353
  finish(proposal.merge(action: 'applied', backup: backup))
354
+ ensure
355
+ Learning.send(:learning_unlock, lock: lock)
352
356
  end
353
357
 
354
358
  private_class_method def self.finish(opts = {})
@@ -89,6 +89,8 @@ module PWN
89
89
  # )
90
90
 
91
91
  public_class_method def self.spawn(opts = {})
92
+ return PWN::Plugins::AISandbox.swarm_control(operation: 'spawn', args: opts) if Thread.current[:pwn_swarm_controller]
93
+
92
94
  name = opts[:name].to_s
93
95
  raise ArgumentError, 'name is required' if name.strip.empty?
94
96
  raise ArgumentError, 'role is required' if opts[:role].to_s.strip.empty?
@@ -120,12 +122,19 @@ module PWN
120
122
  public_class_method def self.retire(opts = {})
121
123
  name = opts[:name].to_s
122
124
  sid = opts[:swarm_id].to_s
125
+ continuation = nil
126
+ if opts[:continuation]
127
+ raise ArgumentError, 'Continuation retirement requires local and swarm_id' unless opts[:local] == true && !sid.empty?
128
+
129
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
130
+ continuation = PWN::SessionCleanup.retire_continuation(root: SWARM_ROOT, swarm_id: sid, name: name)
131
+ end
123
132
  if opts[:local] == true && !sid.empty?
124
133
  path = File.join(SWARM_ROOT, sid, 'agents.yml')
125
134
  all = load_personas_file(path: path)
126
135
  gone = all.delete(name.to_sym)
127
136
  File.write(path, YAML.dump(deep_stringify(hash: all))) if gone
128
- return { name: name, removed: !gone.nil?, scope: :swarm }
137
+ return { name: name, removed: !gone.nil?, scope: :swarm, continuation: continuation }
129
138
  end
130
139
 
131
140
  all = personas
@@ -236,6 +245,8 @@ module PWN
236
245
  empty_tools = false
237
246
  prior_id = Thread.current[:pwn_swarm_id]
238
247
  prior_honesty = Thread.current[:pwn_swarm_honesty]
248
+ return PWN::Plugins::AISandbox.swarm_control(operation: 'ask', args: opts) if Thread.current[:pwn_swarm_controller]
249
+
239
250
  sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
240
251
  persona = opts[:persona] || personas(swarm_id: sid)[name.to_sym]
241
252
  raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
@@ -414,21 +425,28 @@ module PWN
414
425
  sid = opts[:swarm_id].to_s
415
426
  name = opts[:name].to_s
416
427
  map_path = File.join(SWARM_ROOT, sid, 'personas.json')
417
- map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
418
- if map[name]
419
- opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
420
- return map[name]
421
- end
428
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
429
+ PWN::SessionCleanup.with_continuation_lock(root: File.dirname(SWARM_ROOT)) do
430
+ raise IOError, 'Unsafe continuation map' if File.symlink?(map_path)
422
431
 
423
- sess = PWN::Sessions.create(
424
- title: "swarm:#{sid} persona:#{name}",
425
- source: 'pwn-ai-swarm',
426
- active: !opts[:leases].nil?
427
- )
428
- opts[:leases] << sess[:lease] if opts[:leases]
429
- map[name] = sess[:id]
430
- File.write(map_path, JSON.pretty_generate(map))
431
- sess[:id]
432
+ map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
433
+ raise IOError, 'Unknown continuation map' unless map.is_a?(Hash)
434
+
435
+ if map[name]
436
+ opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
437
+ next map[name]
438
+ end
439
+
440
+ sess = PWN::Sessions.create(
441
+ title: "swarm:#{sid} persona:#{name}",
442
+ source: 'pwn-ai-swarm',
443
+ active: !opts[:leases].nil?
444
+ )
445
+ opts[:leases] << sess[:lease] if opts[:leases]
446
+ map[name] = sess[:id]
447
+ PWN::SessionCleanup.publish(map_path, JSON.pretty_generate(map))
448
+ sess[:id]
449
+ end
432
450
  end
433
451
 
434
452
  private_class_method def self.build_persona_prompt(opts = {})
@@ -759,7 +777,8 @@ module PWN
759
777
  #{self}.retire(
760
778
  name: 'required - persona name',
761
779
  swarm_id: 'optional - swarm directory when local is true',
762
- local: 'optional - true retires only that swarm registry'
780
+ local: 'optional - true retires only that swarm registry',
781
+ continuation: 'optional - explicitly retire the inactive saved conversation association; requires local and swarm_id'
763
782
  )
764
783
 
765
784
  # Swarm lifecycle & bus
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'browser_act',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'browser_act',
10
+ description: 'Drive a live browser toward a goal. When Open WebUI has a Jev model, Jev picks each CLICK, TYPE_TEXT, SELECT, scroll, WAIT, DONE, or BLOCKED target from visible controls. The active pwn-ai model writes field text only. If Jev is not configured, the call reports unavailable and does not invent clicks.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ goal: { type: 'string', description: 'Natural-language goal for the current page.' },
15
+ url: { type: 'string', description: 'Optional HTTP(S) URL to open before the first observation.' },
16
+ steps: { type: 'integer', description: 'Maximum decision cycles, 1 to 60. Defaults to 12.' },
17
+ browser_type: { type: 'string', description: 'Browser type when no browser_obj is supplied. Defaults to headless.' }
18
+ },
19
+ required: ['goal']
20
+ }
21
+ },
22
+ handler: lambda { |args|
23
+ args = args.transform_keys(&:to_sym)
24
+ owned = args[:browser_obj].nil?
25
+ browser_obj = args[:browser_obj] || PWN::Plugins::TransparentBrowser.open(browser_type: (args[:browser_type] || :headless).to_sym)
26
+ begin
27
+ PWN::Plugins::TransparentBrowser.act(browser_obj: browser_obj, goal: args[:goal], url: args[:url], steps: args[:steps])
28
+ ensure
29
+ PWN::Plugins::TransparentBrowser.close(browser_obj: browser_obj) if owned && browser_obj
30
+ end
31
+ }
32
+ )
@@ -7,7 +7,7 @@ PWN::AI::Agent::Registry.register(
7
7
  toolset: 'pwn',
8
8
  schema: {
9
9
  name: 'loot_query',
10
- description: 'Query the engagement-scoped encrypted loot store (creds/tokens/keys with provenance). Use for lateral-movement planning; BasicAuth.encode(host:) auto-offers matching secrets when a service prompts for auth.',
10
+ description: 'Query engagement-scoped encrypted loot metadata without decrypting secrets. Store/ingest/offer can select YubiKey hardware protection; hardware access failures never fall back to software. BasicAuth.encode(host:) auto-offers matching secrets when a service prompts for auth.',
11
11
  parameters: {
12
12
  type: 'object',
13
13
  properties: {
@@ -23,6 +23,16 @@ PWN::AI::Agent::Registry.register(
23
23
  username: { type: 'string' },
24
24
  where: { type: 'string' },
25
25
  source: { type: 'string' },
26
+ yubikey: {
27
+ type: 'object',
28
+ description: 'Non-secret selector for an already provisioned HMAC-SHA1 slot. Omit for the existing backend; an empty object selects hardware defaults.',
29
+ properties: {
30
+ slot: { type: 'integer', enum: [1, 2] },
31
+ serial: { type: 'string', pattern: '^[0-9]+$' },
32
+ timeout: { type: 'number', exclusiveMinimum: 0 }
33
+ },
34
+ additionalProperties: false
35
+ },
26
36
  label: { type: 'string' }
27
37
  }
28
38
  }
@@ -7,7 +7,7 @@ PWN::AI::Agent::Registry.register(
7
7
  toolset: 'pwn',
8
8
  schema: {
9
9
  name: 'recon_run',
10
- description: 'Run selected recon modules against one hostname/IP, persist normalized assets with stable IDs and evidence paths, and ingest the asset model for retrieval. Scanner observations are not proven findings.',
10
+ description: 'Run selected recon modules against one hostname/IP, persist normalized assets with stable IDs and evidence paths, and ingest the asset model for retrieval. Optional YubiKey selection protects harvested loot, not raw recon evidence. Scanner observations are not proven findings.',
11
11
  parameters: {
12
12
  type: 'object',
13
13
  properties: {
@@ -15,6 +15,16 @@ PWN::AI::Agent::Registry.register(
15
15
  modules: { type: 'array', minItems: 1, items: { type: 'string', enum: %w[nmap banner tls subfinder nuclei] } },
16
16
  ports: { type: 'array', items: { type: 'integer', minimum: 1, maximum: 65_535 } },
17
17
  engagement_id: { type: 'string' },
18
+ yubikey: {
19
+ type: 'object',
20
+ description: 'Non-secret selector for hardware-encrypted harvested loot using an already provisioned HMAC-SHA1 slot. No software fallback on hardware failure.',
21
+ properties: {
22
+ slot: { type: 'integer', enum: [1, 2] },
23
+ serial: { type: 'string', pattern: '^[0-9]+$' },
24
+ timeout: { type: 'number', exclusiveMinimum: 0 }
25
+ },
26
+ additionalProperties: false
27
+ },
18
28
  timeout: { type: 'number', minimum: 0, maximum: 300 },
19
29
  ingest: { type: 'boolean', default: true }
20
30
  },
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'workspace_tool',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'workspace_tool',
10
+ description: 'Run one provider-hosted tool. Open WebUI workspace tools, including Ask Jev, execute on that server and are not mixed into the client tools array. Other engines expose only their own hosted tools, such as web_search, google_search, or x_search. Ollama has none. Omit name to list the Open WebUI workspace.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ name: { type: 'string', description: 'Workspace tool name, such as Ask Jev, or a hosted tool name such as web_search.' },
15
+ request: { type: 'string', description: 'Question or state to send with the tool. Required unless name is omitted to list Open WebUI tools.' },
16
+ engine: { type: 'string', description: 'Optional provider. Ask Jev uses Open WebUI when that provider is configured, even if another model is active.' }
17
+ },
18
+ required: []
19
+ }
20
+ },
21
+ handler: lambda { |args|
22
+ args = args.transform_keys(&:to_sym)
23
+ if args[:name].to_s.strip.empty?
24
+ PWN::AI::ServerTools.list(engine: :openwebui)
25
+ else
26
+ PWN::AI::ServerTools.call(name: args[:name], request: args[:request], engine: args[:engine])
27
+ end
28
+ }
29
+ )
@@ -209,10 +209,14 @@ module PWN
209
209
  request.to_enum(:scan, %r{["'`]((?:~/|/|\./|\.\./)[^"'`\n]+|[^"'`\n]+\.[A-Za-z0-9]+)["'`]|(?<![\w:/])((?:~/|/|\./|\.\./)[\w.+/-]+|[\w+-]+\.[A-Za-z0-9]+)}).each do
210
210
  match = Regexp.last_match
211
211
  literal = match[1] || match[2].sub(/[.,;:]+\z/, '')
212
+ # Unquoted dotted initials followed by a period are prose (i.e.,
213
+ # e.g., U.S.), not a filename. Quoted literals remain authoritative.
214
+ next if !match[1] && literal.match?(/\A(?:[A-Za-z]\.)+[A-Za-z]\z/) && request[match.end(0)] == '.'
215
+
212
216
  prefix = request[previous_end...match.begin(0)]
213
217
  previous_end = match.end(0)
214
- intent = prefix.scan(/\b(write|save|store|output|export|create|update|modify|edit|generate|put|produce|deliver|read|inspect|review|analy[sz]e|summari[sz]e|from|using)\b/i).flatten.last&.downcase || intent
215
- next unless %w[write save store output export create update modify edit generate put produce deliver].include?(intent)
218
+ intent = prefix.scan(/\b(write|save|store|output|export|create|update|modify|edit|generate|put|produce|deliver|into|mv|move|copy|cp|rename|to|read|inspect|review|analy[sz]e|summari[sz]e|from|using)\b/i).flatten.last&.downcase || intent
219
+ next unless %w[write save store output export create update modify edit generate put produce deliver into mv move copy cp rename to].include?(intent)
216
220
 
217
221
  paths << File.expand_path(literal, opts[:cwd] || Dir.pwd)
218
222
  end
data/lib/pwn/ai/agent.rb CHANGED
@@ -48,6 +48,7 @@ module PWN
48
48
  autoload :TurnFinalizer, 'pwn/ai/agent/turn_finalizer'
49
49
  autoload :Engagement, 'pwn/ai/agent/engagement'
50
50
  autoload :Mission, 'pwn/ai/agent/mission'
51
+ autoload :MissionNamespace, 'pwn/ai/agent/mission_namespace'
51
52
  autoload :OpenGoal, 'pwn/ai/agent/open_goal'
52
53
  autoload :PromptCache, 'pwn/ai/agent/prompt_cache'
53
54
 
@@ -163,7 +163,6 @@ module PWN
163
163
  # bearer lives in-session only.
164
164
  private_class_method def self.persist_oauth_to_vault(opts = {})
165
165
  oauth = opts[:oauth]
166
- return false if Thread.current[:pwn_request_runtime]
167
166
  return false unless oauth.is_a?(Hash)
168
167
  return false unless real_config_value?(value: oauth[:bearer_token])
169
168
 
@@ -178,27 +177,13 @@ module PWN
178
177
  dec_path = dec_path.to_s.strip
179
178
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
180
179
 
181
- unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
180
+ unless File.exist?(env_path)
182
181
  puts '[*] INFO: Anthropic OAuth tokens updated in this session only; ' \
183
- "persistence to #{env_path} skipped (missing decryption artifacts" \
184
- "#{" at #{dec_path}" unless dec_path.empty?})."
182
+ "persistence to #{env_path} skipped (missing vault)."
185
183
  return false
186
184
  end
187
185
 
188
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
189
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
190
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
191
- unless real_config_value?(value: key) && real_config_value?(value: iv)
192
- puts '[*] INFO: Anthropic OAuth tokens updated in this session only; ' \
193
- "persistence to #{env_path} skipped (decryptor at #{dec_path} " \
194
- 'has no usable key/iv).'
195
- return false
196
- end
197
-
198
- PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
199
- begin
200
- cfg = YAML.load_file(env_path, symbolize_names: true)
201
- cfg = {} unless cfg.is_a?(Hash)
186
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
202
187
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
203
188
  cfg[:ai][:anthropic] = {} unless cfg[:ai][:anthropic].is_a?(Hash)
204
189
  vault_oauth = cfg[:ai][:anthropic][:oauth]
@@ -207,19 +192,11 @@ module PWN
207
192
  vault_oauth[:bearer_token] = oauth[:bearer_token]
208
193
  vault_oauth[:refresh_token] = oauth[:refresh_token] if real_config_value?(value: oauth[:refresh_token])
209
194
  vault_oauth[:expires_at] = oauth[:expires_at] if oauth.key?(:expires_at) && !oauth[:expires_at].nil?
210
-
211
- # Match PWN::Config.default_env YAML style (string keys, no leading ':').
212
- yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
213
- File.write(env_path, yaml_env)
214
- File.chmod(0o600, env_path)
215
- ensure
216
- # Always re-encrypt with the IDENTICAL key + iv — never rotate.
217
- PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
218
195
  end
219
196
 
220
197
  true
221
198
  rescue StandardError => e
222
- warn "[!] Anthropic OAuth vault persistence failed (session tokens still updated): #{e.class}: #{e.message}"
199
+ warn "[!] Anthropic OAuth vault persistence failed (session tokens still updated): #{e.class}"
223
200
  false
224
201
  end
225
202
 
@@ -297,14 +274,11 @@ module PWN
297
274
  opts[:refresh_token] = refresh_token if refresh_token
298
275
  opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
299
276
 
277
+ sync_oauth_into_env(oauth: opts)
278
+ persisted = persist_oauth_to_vault(oauth: opts)
300
279
  puts "\n[*] SUCCESS: Anthropic OAuth bearer obtained via authorization_code + PKCE."
301
280
  puts ' Cached in-memory for this pwn / pwn-ai process.'
302
- puts ''
303
- puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
304
- puts " ai.anthropic.oauth.refresh_token = #{refresh_token}" if refresh_token
305
- puts " ai.anthropic.oauth.bearer_token = #{access_token}"
306
- puts ' On future runs the refresh_token alone is enough -- PWN::AI::Anthropic will'
307
- puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
281
+ puts(persisted ? ' Saved to the encrypted configuration vault.' : ' Session only: encrypted vault persistence unavailable.')
308
282
  puts ''
309
283
 
310
284
  access_token
@@ -614,6 +588,7 @@ module PWN
614
588
  end
615
589
  http_body[:tool_choice] = anth_tool_choice(choice: opts[:tool_choice], model: model) if opts[:tool_choice]
616
590
  end
591
+ http_body = PWN::AI::ServerTools.attach(engine: :anthropic, http_body: http_body, request: opts)
617
592
  effort = opts[:reasoning_effort].to_s
618
593
  if !effort.empty? && effort != 'none' && reasoning_efforts(model: model)[:efforts].include?(effort)
619
594
  http_body[:output_config] = { effort: effort }