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/ai/cli.rb CHANGED
@@ -109,12 +109,13 @@ module PWN
109
109
  prompt = (opts[:input] || $stdin).read if prompt == '-'
110
110
  raise ArgumentError, '--ai requires a non-empty prompt' if prompt.strip.empty?
111
111
 
112
- outcome = nil
113
112
  contract = JSON.parse(File.read(parsed[:verification_contract]), symbolize_names: true) if parsed[:verification_contract]
114
- output.puts(PWN::AI::Agent::Loop.run(request: prompt, session_id: session[:id], enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
115
- verification_contract: contract,
116
- require_resumable: parsed.key?(:resume_session),
117
- on_outcome: ->(row) { outcome = row }))
113
+ response = mission_request(request: prompt, session_id: session[:id], enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
114
+ verification_contract: contract, require_resumable: parsed.key?(:resume_session))
115
+ return response[:exit_status] if response[:exit_status]
116
+
117
+ output.puts(response[:final])
118
+ outcome = response[:outcome]
118
119
  return outcome && outcome[:status] == 'complete' ? 0 : 1
119
120
  else
120
121
  PWN::Plugins::REPL.start(ai_session_id: session[:id], ai_session_lease: session[:lease])
@@ -124,6 +125,34 @@ module PWN
124
125
  PWN::Sessions.deactivate(lease: session[:lease]) if session
125
126
  end
126
127
 
128
+ private_class_method def self.mission_request(opts = {})
129
+ reader, writer = IO.pipe
130
+ mission = PWN::AI::Agent::MissionNamespace.new(descriptor: { operation: :loop, options: opts }, fds: { 4 => writer })
131
+ writer.close
132
+ raw = reader.read
133
+ mission.wait
134
+ return { exit_status: mission.status.exitstatus || 1 } if raw.empty?
135
+
136
+ response = JSON.parse(raw, symbolize_names: true)
137
+ raise IOError, response[:error] if response[:error]
138
+
139
+ response
140
+ ensure
141
+ mission&.close
142
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
143
+ end
144
+
145
+ private_class_method def self.mission_entry(opts = {})
146
+ writer = PWN::AI::Agent::MissionNamespace.io(4)
147
+ outcome = nil
148
+ final = PWN::AI::Agent::Loop.run(opts.fetch(:options).merge(on_outcome: ->(row) { outcome = row }))
149
+ writer.write(JSON.generate(final: final, outcome: outcome))
150
+ rescue StandardError => e
151
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
152
+ ensure
153
+ writer.close
154
+ end
155
+
127
156
  private_class_method def self.validate_request_options(opts = {})
128
157
  parsed = opts[:parsed]
129
158
  selected = parsed[:resume_session] || parsed[:replace_session]
data/lib/pwn/ai/gemini.rb CHANGED
@@ -238,6 +238,7 @@ module PWN
238
238
  }]
239
239
  http_body[:toolConfig] = gemini_tool_config(choice: opts[:tool_choice]) if opts[:tool_choice]
240
240
  end
241
+ http_body = PWN::AI::ServerTools.attach(engine: :gemini, http_body: http_body, request: opts)
241
242
 
242
243
  response = gemini_rest_call(
243
244
  http_method: :post,
data/lib/pwn/ai/grok.rb CHANGED
@@ -167,27 +167,13 @@ module PWN
167
167
  dec_path = dec_path.to_s.strip
168
168
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
169
169
 
170
- unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
170
+ unless File.exist?(env_path)
171
171
  puts '[*] INFO: Grok OAuth tokens updated in this session only; ' \
172
- "persistence to #{env_path} skipped (missing decryption artifacts" \
173
- "#{" at #{dec_path}" unless dec_path.empty?})."
172
+ "persistence to #{env_path} skipped (missing vault)."
174
173
  return false
175
174
  end
176
175
 
177
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
178
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
179
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
180
- unless real_config_value?(value: key) && real_config_value?(value: iv)
181
- puts '[*] INFO: Grok OAuth tokens updated in this session only; ' \
182
- "persistence to #{env_path} skipped (decryptor at #{dec_path} " \
183
- 'has no usable key/iv).'
184
- return false
185
- end
186
-
187
- PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
188
- begin
189
- cfg = YAML.load_file(env_path, symbolize_names: true)
190
- cfg = {} unless cfg.is_a?(Hash)
176
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
191
177
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
192
178
  cfg[:ai][:grok] = {} unless cfg[:ai][:grok].is_a?(Hash)
193
179
  vault_oauth = cfg[:ai][:grok][:oauth]
@@ -196,19 +182,11 @@ module PWN
196
182
  vault_oauth[:bearer_token] = oauth[:bearer_token]
197
183
  vault_oauth[:refresh_token] = oauth[:refresh_token] if real_config_value?(value: oauth[:refresh_token])
198
184
  vault_oauth[:expires_at] = oauth[:expires_at] if oauth.key?(:expires_at) && !oauth[:expires_at].nil?
199
-
200
- # Match PWN::Config.default_env YAML style (string keys, no leading ':').
201
- yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
202
- File.write(env_path, yaml_env)
203
- File.chmod(0o600, env_path)
204
- ensure
205
- # Always re-encrypt with the IDENTICAL key + iv — never rotate.
206
- PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
207
185
  end
208
186
 
209
187
  true
210
188
  rescue StandardError => e
211
- warn "[!] Grok OAuth vault persistence failed (session tokens still updated): #{e.class}: #{e.message}"
189
+ warn "[!] Grok OAuth vault persistence failed (session tokens still updated): #{e.class}"
212
190
  false
213
191
  end
214
192
 
@@ -496,7 +474,7 @@ module PWN
496
474
  end
497
475
  retry if retry_count < max_attempts
498
476
 
499
- raise e if Thread.current[:pwn_provider_attempt_owner]
477
+ raise e if Thread.current[:pwn_provider_attempt_owner] || Thread.current[:pwn_session_title]
500
478
 
501
479
  nil
502
480
  end
@@ -554,6 +532,30 @@ module PWN
554
532
  nil
555
533
  end
556
534
 
535
+ private_class_method def self.hosted_responses(opts = {})
536
+ input = Array(opts[:messages]).filter_map do |msg|
537
+ next unless msg.is_a?(Hash)
538
+
539
+ role = (msg[:role] || msg['role']).to_s
540
+ content = msg[:content] || msg['content']
541
+ next if content.to_s.empty?
542
+
543
+ { role: role, content: content.to_s }
544
+ end
545
+ raw = grok_rest_call(
546
+ http_method: :post,
547
+ rest_call: 'responses',
548
+ http_body: { model: opts[:model], input: input, tools: PWN::AI::ServerTools.wire(engine: :grok, names: opts[:names]), stream: false },
549
+ timeout: opts[:timeout],
550
+ spinner: opts[:spinner],
551
+ quiet: opts[:quiet]
552
+ )
553
+ parsed = JSON.parse(raw.to_s, symbolize_names: true)
554
+ text = parsed[:output_text].to_s
555
+ message = { role: 'assistant', content: text }
556
+ { choices: [{ message: message }], assistant_message: message }
557
+ end
558
+
557
559
  # Supported Method Parameters::
558
560
  # response = PWN::AI::Grok.chat_with_tools(
559
561
  # messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -611,6 +613,12 @@ module PWN
611
613
  http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
612
614
  effort = opts[:reasoning_effort].to_s
613
615
  http_body[:reasoning_effort] = effort unless effort.empty? || effort == 'none'
616
+ hosted = Array(opts[:server_tools]).map(&:to_s).reject(&:empty?)
617
+ if hosted.any?
618
+ raise ArgumentError, 'xAI hosted tools use the Responses API and are not mixed into chat/completions client tools' if opts[:tools] && !opts[:tools].empty?
619
+
620
+ return hosted_responses(model: model, messages: messages, names: hosted, timeout: timeout, spinner: spinner, quiet: opts[:quiet])
621
+ end
614
622
 
615
623
  response = grok_rest_call(
616
624
  http_method: :post,
@@ -690,6 +698,8 @@ module PWN
690
698
  temperature: temp,
691
699
  stream: false
692
700
  }
701
+ effort = opts[:reasoning_effort].to_s
702
+ http_body[:reasoning_effort] = effort unless effort.empty?
693
703
 
694
704
  if response_history[:choices].length > 1
695
705
  response_history[:choices][1..-1].each do |message|
@@ -787,7 +797,8 @@ module PWN
787
797
  timeout: 'optional - seconds (default 180)',
788
798
  spinner: 'optional - display spinner (default false)',
789
799
  quiet: 'optional - quiet value consumed by #chat_with_tools',
790
- reasoning_effort: 'optional - Grok reasoning effort (default medium; none disables thinking)'
800
+ reasoning_effort: 'optional - Grok reasoning effort (default medium; none disables thinking)',
801
+ server_tools: 'optional - hosted tool names such as web_search; sent on the Responses API only when client tools are omitted'
791
802
  )
792
803
 
793
804
  # Run chat and return its result
data/lib/pwn/ai/ollama.rb CHANGED
@@ -198,7 +198,7 @@ module PWN
198
198
  end
199
199
  retry if retry_count < max_attempts
200
200
 
201
- raise e if Thread.current[:pwn_provider_attempt_owner]
201
+ raise e if Thread.current[:pwn_provider_attempt_owner] || Thread.current[:pwn_session_title]
202
202
 
203
203
  nil
204
204
  rescue StandardError => e
@@ -671,6 +671,7 @@ module PWN
671
671
  end
672
672
  http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
673
673
  http_body[:think] = think_wire(reasoning_effort: opts[:reasoning_effort], think: opts[:think])
674
+ raise ArgumentError, 'Ollama has no server-side tool workspace. Its tools are client-executed.' if Array(opts[:server_tools]).any?
674
675
 
675
676
  response = ollama_rest_call(
676
677
  http_method: :post,
@@ -837,7 +838,8 @@ module PWN
837
838
  timeout: 'optional - seconds (default 900)',
838
839
  spinner: 'optional - display spinner (default false)',
839
840
  think: 'optional - true enables Ollama think (default true; false disables)',
840
- reasoning_effort: 'optional - think level from /api/show thinking.values'
841
+ reasoning_effort: 'optional - think level from /api/show thinking.values',
842
+ server_tools: 'optional - rejected; Ollama has no server-side tool workspace'
841
843
  )
842
844
 
843
845
  # Run chat and return its result
@@ -172,7 +172,6 @@ module PWN
172
172
  # bearer lives in-session only.
173
173
  private_class_method def self.persist_oauth_to_vault(opts = {})
174
174
  oauth = opts[:oauth]
175
- return false if Thread.current[:pwn_request_runtime]
176
175
  return false unless oauth.is_a?(Hash)
177
176
  return false unless real_config_value?(value: oauth[:bearer_token])
178
177
 
@@ -187,32 +186,15 @@ module PWN
187
186
  dec_path = dec_path.to_s.strip
188
187
  dec_path = "#{env_path}.decryptor" if dec_path.empty?
189
188
 
190
- unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
189
+ unless File.exist?(env_path)
191
190
  puts '[*] INFO: OpenAI OAuth tokens updated in this session only; ' \
192
- "persistence to #{env_path} skipped (missing decryption artifacts" \
193
- "#{" at #{dec_path}" unless dec_path.empty?})."
191
+ "persistence to #{env_path} skipped (missing vault)."
194
192
  return false
195
193
  end
196
194
 
197
- decryptor = YAML.load_file(dec_path, symbolize_names: true)
198
- key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
199
- iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
200
- unless real_config_value?(value: key) && real_config_value?(value: iv)
201
- puts '[*] INFO: OpenAI OAuth tokens updated in this session only; ' \
202
- "persistence to #{env_path} skipped (decryptor at #{dec_path} " \
203
- 'has no usable key/iv).'
204
- return false
205
- end
206
-
207
- # Work on a private sibling; never decrypt the live vault in place.
208
- # Rename only after encryption succeeds, preserving the old vault on
209
- # write/encryption errors and keeping the existing key + iv intact.
210
- Tempfile.create(['.pwn-openai-oauth-', '.yaml'], File.dirname(env_path)) do |temp|
211
- temp.write(File.binread(env_path))
212
- temp.flush
213
- PWN::Plugins::Vault.decrypt(file: temp.path, key: key, iv: iv)
214
- cfg = YAML.load_file(temp.path, symbolize_names: true)
215
- cfg = {} unless cfg.is_a?(Hash)
195
+ # The shared updater resolves hardware or legacy credentials, keeps
196
+ # plaintext in memory and publishes only encrypted bytes atomically.
197
+ PWN::Plugins::Vault.update(file: env_path, decryptor_file: dec_path) do |cfg|
216
198
  cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
217
199
  cfg[:ai][:openai] = {} unless cfg[:ai][:openai].is_a?(Hash)
218
200
  vault_oauth = cfg[:ai][:openai][:oauth]
@@ -223,13 +205,6 @@ module PWN
223
205
  vault_oauth[:id_token] = oauth[:id_token] if real_config_value?(value: oauth[:id_token])
224
206
  vault_oauth[:account_id] = oauth[:account_id] if real_config_value?(value: oauth[:account_id])
225
207
  vault_oauth[:expires_at] = oauth[:expires_at] if oauth.key?(:expires_at) && !oauth[:expires_at].nil?
226
-
227
- # Match PWN::Config.default_env YAML style (string keys, no leading ':').
228
- yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
229
- File.write(temp.path, yaml_env)
230
- PWN::Plugins::Vault.encrypt(file: temp.path, key: key, iv: iv)
231
- temp.fsync
232
- File.rename(temp.path, env_path)
233
208
  end
234
209
 
235
210
  true
@@ -563,7 +538,7 @@ module PWN
563
538
  end
564
539
  retry if retry_count < max_attempts
565
540
 
566
- raise e if oauth_selected || Thread.current[:pwn_provider_attempt_owner]
541
+ raise e if oauth_selected || Thread.current[:pwn_provider_attempt_owner] || Thread.current[:pwn_session_title]
567
542
 
568
543
  nil
569
544
  end
@@ -717,6 +692,7 @@ module PWN
717
692
  reasoning_effort: effort
718
693
  )
719
694
  end
695
+ http_body = PWN::AI::ServerTools.attach(engine: :openai, http_body: http_body, request: opts)
720
696
 
721
697
  response = open_ai_rest_call(
722
698
  http_method: :post,
@@ -1039,7 +1015,10 @@ module PWN
1039
1015
  }
1040
1016
  # Reasoning models reject sampler params (temperature, top_p, etc.)
1041
1017
  http_body[:temperature] = temp unless reasoning
1042
- http_body[:reasoning_effort] = opts[:reasoning_effort] if reasoning && opts[:reasoning_effort]
1018
+ effort = opts[:reasoning_effort].to_s
1019
+ if reasoning && !effort.empty?
1020
+ http_body[:reasoning_effort] = effort == 'none' ? 'minimal' : effort
1021
+ end
1043
1022
 
1044
1023
  response = open_ai_rest_call(
1045
1024
  http_method: :post,
@@ -223,7 +223,7 @@ module PWN
223
223
  end
224
224
  retry if retry_count < max_attempts
225
225
 
226
- raise e if Thread.current[:pwn_provider_attempt_owner]
226
+ raise e if Thread.current[:pwn_provider_attempt_owner] || Thread.current[:pwn_session_title]
227
227
 
228
228
  nil
229
229
  end
@@ -650,6 +650,47 @@ module PWN
650
650
  effort
651
651
  end
652
652
 
653
+ # Supported Method Parameters::
654
+ # rows = PWN::AI::OpenWebUI.workspace_tools(
655
+ # refresh: 'optional - true ignores a cached workspace tool list',
656
+ # timeout: 'optional - seconds for the tool list request (default 30)'
657
+ # )
658
+ public_class_method def self.workspace_tools(opts = {})
659
+ refresh = opts[:refresh]
660
+ cached = Thread.current[:pwn_openwebui_workspace_tools]
661
+ return cached if cached && !refresh
662
+
663
+ raw = openwebui_rest_call(http_method: :get, rest_call: 'api/v1/tools/', timeout: opts[:timeout] || 30, quiet: true)
664
+ text = raw.is_a?(String) ? raw : raw.to_s
665
+ parsed = text.strip.empty? ? [] : JSON.parse(text)
666
+ rows = parsed.is_a?(Hash) ? (parsed['data'] || parsed[:data] || []) : parsed
667
+ listed = Array(rows).filter_map do |row|
668
+ next unless row.is_a?(Hash)
669
+
670
+ id = row['id'] || row[:id]
671
+ name = row['name'] || row[:name]
672
+ next if id.to_s.empty?
673
+
674
+ meta = row['meta'] || row[:meta] || {}
675
+ { id: id.to_s, name: name.to_s, description: meta['description'] || meta[:description] || row['description'] || row[:description] }
676
+ end
677
+ Thread.current[:pwn_openwebui_workspace_tools] = listed
678
+ listed
679
+ end
680
+
681
+ private_class_method def self.server_tool_ids(opts = {})
682
+ names = Array(opts[:names]).map(&:to_s).reject(&:empty?)
683
+ return [] if names.empty?
684
+
685
+ listed = workspace_tools
686
+ names.map do |name|
687
+ row = listed.find { |item| item[:id] == name || item[:name].casecmp?(name) }
688
+ raise ArgumentError, "Open WebUI workspace has no tool #{name}" unless row
689
+
690
+ row[:id]
691
+ end
692
+ end
693
+
653
694
  # Supported Method Parameters::
654
695
  # response = PWN::AI::OpenWebUI.chat_with_tools(
655
696
  # messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -704,6 +745,7 @@ module PWN
704
745
  end
705
746
  http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
706
747
  http_body[:think] = think_wire(reasoning_effort: opts[:reasoning_effort], think: opts[:think])
748
+ raise ArgumentError, 'Open WebUI workspace tools do not run on the proxied Ollama chat path. Use chat(server_tools:) or workspace_tool.' if Array(opts[:server_tools]).any?
707
749
 
708
750
  response = openwebui_rest_call(
709
751
  http_method: :post,
@@ -797,6 +839,13 @@ module PWN
797
839
  messages: http_body[:messages],
798
840
  fold_into_user: strict_system_template?(model: model)
799
841
  )
842
+ server_ids = server_tool_ids(names: opts[:server_tools])
843
+ if server_ids.any?
844
+ http_body[:stream] = false
845
+ http_body[:tool_ids] = server_ids
846
+ http_body[:params] = { function_calling: 'legacy' }
847
+ http_body.delete(:tools)
848
+ end
800
849
 
801
850
  timeout = opts[:timeout]
802
851
  spinner = opts[:spinner]
@@ -809,7 +858,8 @@ module PWN
809
858
  spinner: spinner
810
859
  )
811
860
 
812
- json_resp = JSON.parse(response, symbolize_names: true)
861
+ raw = response.is_a?(String) ? response : response.to_s
862
+ json_resp = JSON.parse(raw, symbolize_names: true)
813
863
  assistant_resp = json_resp[:choices].first[:message]
814
864
  if assistant_resp.is_a?(Hash)
815
865
  content = assistant_resp[:content].to_s
@@ -857,6 +907,12 @@ module PWN
857
907
  fallback: 'optional - false skips the catalog list when the direct route misses'
858
908
  )
859
909
 
910
+ # List workspace tools installed for this Open WebUI user, including Ask Jev.
911
+ #{self}.workspace_tools(
912
+ refresh: 'optional - true ignores a cached workspace tool list',
913
+ timeout: 'optional - seconds for the tool list request (default 30)'
914
+ )
915
+
860
916
  # Run chat with tools and return its result
861
917
  #{self}.chat_with_tools(
862
918
  messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -867,7 +923,8 @@ module PWN
867
923
  timeout: 'optional - seconds (default 900)',
868
924
  spinner: 'optional - display spinner (default false)',
869
925
  think: 'optional - true enables backend think (default true; false disables)',
870
- reasoning_effort: 'optional - think level when the proxied model publishes named levels'
926
+ reasoning_effort: 'optional - think level when the proxied model publishes named levels',
927
+ server_tools: 'optional - rejected here; workspace tools use chat(server_tools:) or workspace_tool'
871
928
  )
872
929
 
873
930
  # Run chat and return its result
@@ -879,7 +936,8 @@ module PWN
879
936
  response_history: 'optional - pass response back in to have a conversation',
880
937
  speak_answer: 'optional - optional speak answer using PWN::Plugins::Voice.text_to_speech (Default: nil)',
881
938
  timeout: 'optional - optional timeout in seconds (defaults to 900)',
882
- spinner: 'optional - display spinner (defaults to false)'
939
+ spinner: 'optional - display spinner (defaults to false)',
940
+ server_tools: 'optional - workspace tool names or ids, such as Ask Jev; the server executes them and this request omits client tools'
883
941
  )
884
942
 
885
943
  # Print the AUTHOR(S) string for this module.
data/lib/pwn/ai/router.rb CHANGED
@@ -59,7 +59,7 @@ module PWN
59
59
 
60
60
  extractive(text: text)
61
61
  rescue StandardError
62
- return '' if session
62
+ raise if session
63
63
 
64
64
  extractive(text: text)
65
65
  end
@@ -162,15 +162,65 @@ module PWN
162
162
  mod = Object.const_get(const)
163
163
  return nil unless mod.respond_to?(:chat)
164
164
 
165
- resp = mod.chat(request: opts[:request], model: endpoint[:model], spinner: false, quiet: true, timeout: 8)
166
- text = resp.is_a?(Hash) ? (resp[:content] || resp['content'] || resp.dig(:choices, -1, :content)).to_s : resp.to_s
165
+ resp = mod.chat(
166
+ request: opts[:request],
167
+ model: endpoint[:model],
168
+ spinner: false,
169
+ quiet: true,
170
+ timeout: opts[:session] ? 90 : 8,
171
+ reasoning_effort: (opts[:session] ? 'none' : nil)
172
+ )
173
+ text = response_text(response: resp)
167
174
  text.strip.empty? ? nil : text
168
175
  rescue StandardError
176
+ raise if opts[:session]
177
+
169
178
  nil
170
179
  ensure
171
180
  Thread.current[:pwn_session_title] = previous_title_worker
172
181
  end
173
182
 
183
+ # Only decode documented assistant-content locations, never container inspect
184
+ # strings, reasoning blocks, tool arguments or echoed user messages.
185
+ private_class_method def self.response_text(opts = {})
186
+ response = opts[:response]
187
+ return response if response.is_a?(String)
188
+ return '' unless response.is_a?(Hash)
189
+
190
+ row = response.transform_keys(&:to_s)
191
+ return JSON.generate(row) if row['title'].is_a?(String) && row['summary'].is_a?(String)
192
+ return content_text(content: row['content']) if row.key?('content')
193
+
194
+ choice = Array(row['choices']).last
195
+ if choice.is_a?(Hash)
196
+ choice = choice.transform_keys(&:to_s)
197
+ message = choice['message'] || choice
198
+ if message.is_a?(Hash)
199
+ message = message.transform_keys(&:to_s)
200
+ return '' if message['role'] && message['role'] != 'assistant'
201
+
202
+ return content_text(content: message['content'])
203
+ end
204
+ end
205
+ message = row['message']
206
+ return content_text(content: message[:content] || message['content']) if message.is_a?(Hash)
207
+
208
+ ''
209
+ end
210
+
211
+ private_class_method def self.content_text(opts = {})
212
+ content = opts[:content]
213
+ return content if content.is_a?(String)
214
+ return '' unless content.is_a?(Array)
215
+
216
+ content.filter_map do |block|
217
+ next unless block.is_a?(Hash)
218
+
219
+ block = block.transform_keys(&:to_s)
220
+ block['text'] if %w[text output_text].include?(block['type']) && block['text'].is_a?(String)
221
+ end.join
222
+ end
223
+
174
224
  private_class_method def self.active_session_endpoint
175
225
  engine = env_dig(keys: %i[ai active]).to_s.downcase
176
226
  { engine: engine, model: env_dig(keys: [:ai, engine.to_sym, :model]) }