pwn 0.5.784 → 0.5.787

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'PWN::AI::Agent::Tools browser_act' do
6
+ before(:all) do
7
+ PWN::AI::Agent::Registry.discover(force: true)
8
+ load '/opt/pwn/lib/pwn/ai/agent/tools/browser_act.rb'
9
+ end
10
+
11
+ it 'registers browser_act' do
12
+ expect(PWN::AI::Agent::Registry.lookup(name: 'browser_act')).not_to be_nil
13
+ end
14
+
15
+ it 'opens, acts, and closes when no browser is supplied' do
16
+ browser_obj = { type: :headless, browser: :stub }
17
+ allow(PWN::Plugins::TransparentBrowser).to receive(:open).with(browser_type: :headless).and_return(browser_obj)
18
+ allow(PWN::Plugins::TransparentBrowser).to receive(:act).and_return({ available: true, status: 'done' })
19
+ allow(PWN::Plugins::TransparentBrowser).to receive(:close)
20
+
21
+ result = PWN::AI::Agent::Registry.lookup(name: 'browser_act').handler.call(
22
+ 'goal' => 'find the article',
23
+ 'url' => 'https://example.test'
24
+ )
25
+
26
+ expect(result).to include(available: true, status: 'done')
27
+ expect(PWN::Plugins::TransparentBrowser).to have_received(:act).with(
28
+ hash_including(browser_obj: browser_obj, goal: 'find the article', url: 'https://example.test')
29
+ )
30
+ expect(PWN::Plugins::TransparentBrowser).to have_received(:close).with(browser_obj: browser_obj)
31
+ end
32
+
33
+ it 'does not close a browser the caller already owns' do
34
+ supplied = { type: :rest, browser: :kept }
35
+ allow(PWN::Plugins::TransparentBrowser).to receive(:open)
36
+ allow(PWN::Plugins::TransparentBrowser).to receive(:act).and_return({ available: false, status: 'unavailable' })
37
+ allow(PWN::Plugins::TransparentBrowser).to receive(:close)
38
+
39
+ PWN::AI::Agent::Registry.lookup(name: 'browser_act').handler.call(browser_obj: supplied, goal: 'look')
40
+
41
+ expect(PWN::Plugins::TransparentBrowser).not_to have_received(:open)
42
+ expect(PWN::Plugins::TransparentBrowser).not_to have_received(:close)
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json_schemer'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe 'loot_query hardware selector' do
8
+ before(:all) do
9
+ PWN::AI::Agent::Registry.discover
10
+ end
11
+
12
+ it 'rejects an executable override at the actual Dispatch schema boundary' do
13
+ allow(PWN::AI::Agent::Manifest).to receive(:load).and_return({})
14
+ expect(PWN::Plugins::Vault).not_to receive(:store)
15
+ expect(PWN::Plugins::Vault).not_to receive(:expand)
16
+ raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'loot_query', arguments: { op: 'store', secret: 'fixture-secret', yubikey: { ykman: '/fixture/arbitrary-executable' } } } })
17
+ expect(JSON.parse(raw)).to include('success' => false, 'code' => 'SCHEMA_DENY')
18
+ end
19
+
20
+ it 'forwards selection through ingest while metadata-only query never opens the device' do
21
+ Dir.mktmpdir('pwn-loot-query-yubikey-') do |root|
22
+ allow(Dir).to receive(:home).and_return(root)
23
+ allow(PWN::AI::Agent::Mission).to receive(:active_id).and_return(nil)
24
+ device = double('offline YubiKey fixture')
25
+ stub_const('PWN::Plugins::YubiKey', device)
26
+ allow(device).to receive(:challenge_response) do |opts|
27
+ expect(opts).to include(slot: 2, serial: '12345678', timeout: 2.5)
28
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
29
+ end
30
+ handler = PWN::AI::Agent::Registry.lookup(name: 'loot_query').handler
31
+ saved = handler.call('op' => 'ingest', 'text' => 'username=fixture-user password=fixture-password', 'engagement' => 'lab',
32
+ 'yubikey' => { 'slot' => 2, 'serial' => '12345678', 'timeout' => 2.5 })
33
+ expect(saved.first).to include(stored: true, engagement: 'lab', source: 'recon')
34
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
35
+ expect(device).not_to receive(:challenge_response)
36
+ result = handler.call('op' => 'query', 'engagement' => 'lab')
37
+ expect(result[:records].first).to include(has_secret: true, username: 'fixture-user')
38
+ expect(result.to_s).not_to include('fixture-password')
39
+ end
40
+ end
41
+
42
+ it 'advertises only a non-secret hardware selector without adding a tool' do
43
+ count = PWN::AI::Agent::Registry.all.length
44
+ load File.expand_path('../../../../../../lib/pwn/ai/agent/tools/loot_query.rb', __dir__)
45
+ expect(PWN::AI::Agent::Registry.all.length).to eq(count)
46
+ schema = PWN::AI::Agent::Registry.lookup(name: 'loot_query').schema.fetch(:parameters)
47
+ selector = schema.fetch(:properties).fetch(:yubikey)
48
+ expect(selector.fetch(:properties).keys).to contain_exactly(:slot, :serial, :timeout)
49
+ validator = JSONSchemer.schema(JSON.parse(JSON.generate(schema)))
50
+ expect(validator.valid?({ 'op' => 'store', 'yubikey' => { 'slot' => 2, 'serial' => '12345678', 'timeout' => 2.5 } })).to be(true)
51
+ [{ 'ykman' => '/fixture/arbitrary-executable' }, { 'pin' => '123456' }, { 'slot' => 3 }, { 'serial' => 'invalid' }, { 'timeout' => 0 }, false].each do |invalid|
52
+ expect(validator.valid?({ 'yubikey' => invalid })).to be(false), invalid.inspect
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json_schemer'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe 'recon_run hardware selector' do
8
+ before do
9
+ PWN::AI::Agent::Registry.discover
10
+ end
11
+
12
+ it 'advertises the nonsecret selector and rejects executable overrides before recon runs' do
13
+ entry = PWN::AI::Agent::Registry.lookup(name: 'recon_run')
14
+ selector = entry.schema.fetch(:parameters).fetch(:properties).fetch(:yubikey)
15
+ expect(selector.fetch(:properties).keys).to contain_exactly(:slot, :serial, :timeout)
16
+ validator = JSONSchemer.schema(JSON.parse(JSON.generate(entry.schema.fetch(:parameters))))
17
+ expect(validator.valid?({ 'target' => 'app.example.test', 'modules' => ['banner'], 'yubikey' => { 'slot' => 2, 'serial' => '12345678' } })).to be(true)
18
+ allow(PWN::AI::Agent::Manifest).to receive(:load).and_return({})
19
+ expect(PWN::Plugins::Recon).not_to receive(:run)
20
+ raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'recon_run', arguments: { target: 'app.example.test', modules: ['banner'], yubikey: { ykman: '/fixture/untrusted-executable' } } } })
21
+ expect(JSON.parse(raw)).to include('success' => false, 'code' => 'SCHEMA_DENY')
22
+ end
23
+
24
+ it 'carries JSON selector keys through the real recon handler into hardware-protected loot' do
25
+ Dir.mktmpdir('pwn-recon-tool-yubikey-') do |root|
26
+ allow(Dir).to receive(:home).and_return(root)
27
+ allow(PWN::AI::Agent::Mission).to receive(:active_id).and_return(nil)
28
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
29
+ expect(opts).to include(slot: 2, serial: '12345678', timeout: 4)
30
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
31
+ end
32
+ socket = double('offline banner', readpartial: "username=fixture-user\npassword=fixture-password\n", remote_address: double(ip_address: '192.0.2.10'))
33
+ allow(Socket).to receive(:tcp).and_yield(socket)
34
+ entry = PWN::AI::Agent::Registry.lookup(name: 'recon_run')
35
+ selector = { 'slot' => 2, 'serial' => '12345678', 'timeout' => 4 }
36
+ result = entry.handler.call('target' => 'app.example.test', 'modules' => ['banner'], 'ports' => [22], 'root' => root,
37
+ 'ingest' => false, 'engagement_id' => 'lab', 'yubikey' => selector)
38
+ label = result.fetch(:loot).first.fetch(:label)
39
+ expect(PWN::Plugins::Vault.fetch(label: label, engagement: 'lab', yubikey: selector)).to eq('fixture-password')
40
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'PWN::AI::Agent::Tools workspace_tool' do
6
+ before(:all) do
7
+ PWN::AI::Agent::Registry.discover(force: true)
8
+ load '/opt/pwn/lib/pwn/ai/agent/tools/workspace_tool.rb'
9
+ end
10
+
11
+ it 'registers workspace_tool' do
12
+ expect(PWN::AI::Agent::Registry.lookup(name: 'workspace_tool')).not_to be_nil
13
+ end
14
+
15
+ it 'lists the Open WebUI workspace when no name is given' do
16
+ allow(PWN::AI::ServerTools).to receive(:list).with(engine: :openwebui).and_return([{ id: 'ask_jev', name: 'Ask Jev' }])
17
+ result = PWN::AI::Agent::Registry.lookup(name: 'workspace_tool').handler.call({})
18
+ expect(result).to eq([{ id: 'ask_jev', name: 'Ask Jev' }])
19
+ end
20
+ end
@@ -11,6 +11,12 @@ describe PWN::AI::Agent::TurnFinalizer do
11
11
  expect(described_class).to respond_to :help
12
12
  end
13
13
 
14
+ it 'does not promote prose abbreviations into output filenames' do
15
+ request = 'Generate a report, i.e. a PDF; save /tmp/container_pentest-PWN.pdf'
16
+ expect(described_class.output_paths(request: request)).to eq(['/tmp/container_pentest-PWN.pdf'])
17
+ expect(described_class.output_paths(request: 'Write `i.e`')).to eq([File.expand_path('i.e')])
18
+ end
19
+
14
20
  describe 'user-path depth' do
15
21
  after { described_class.leave_user_path! while described_class.user_path? }
16
22
 
@@ -145,6 +151,7 @@ describe PWN::AI::Agent::TurnFinalizer do
145
151
 
146
152
  it 'preserves extensionless and quoted destination literals without inventing filenames' do
147
153
  expect(described_class.output_paths(request: 'Write the result to /tmp/answer')).to eq(['/tmp/answer'])
154
+ expect(described_class.output_paths(request: 'Are you able to mv foo to /tmp/test?')).to eq(['/tmp/test'])
148
155
  expect(described_class.output_paths(request: 'Save to "~/My Reports/answer.md"')).to eq([File.expand_path('~/My Reports/answer.md')])
149
156
  expect(described_class.output_paths(request: 'Use /tmp/source.md to write the report to ./result.md')).to eq([File.expand_path('./result.md')])
150
157
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'stringio'
6
+
7
+ RSpec.describe PWN::AI::Anthropic, 'hardware vault enrollment persistence' do
8
+ it 'persists initial enrollment as well as refresh without printing returned credentials' do
9
+ Dir.mktmpdir('anthropic-yubikey-') do |root|
10
+ allow(Dir).to receive(:home).and_return(root)
11
+ path = File.join(root, 'vault')
12
+ config = { ai: { active: :anthropic, anthropic: { model: 'fixture-model', oauth: {} } }, driver_opts: { pwn_env_path: path } }
13
+ stub_const('PWN::Env', config)
14
+ device = double('offline YubiKey')
15
+ stub_const('PWN::Plugins::YubiKey', device)
16
+ allow(device).to receive(:challenge_response) { |opts| OpenSSL::HMAC.digest('SHA1', 'fixture-slot', opts[:challenge]) }
17
+ File.write(path, YAML.dump(config.except(:driver_opts)))
18
+ PWN::Plugins::Vault.create(file: path, yubikey: {})
19
+ allow(RestClient).to receive(:post).and_return(double(body: JSON.generate(access_token: 'synthetic-access-token', refresh_token: 'synthetic-refresh-token', expires_in: 3600)))
20
+ previous_input = $stdin
21
+ previous_output = $stdout
22
+ begin
23
+ $stdin = StringIO.new("synthetic-authorization-code\n")
24
+ $stdout = StringIO.new
25
+ expect(described_class.obtain_oauth_bearer_token).to eq('synthetic-access-token')
26
+ expect($stdout.string).not_to include('synthetic-access-token', 'synthetic-refresh-token')
27
+ expect(PWN::Env.dig(:ai, :anthropic, :oauth)).to include(bearer_token: 'synthetic-access-token')
28
+ persisted = PWN::Plugins::Vault.dump(file: path)
29
+ expect(persisted.dig(:ai, :anthropic, :oauth)).to include(bearer_token: 'synthetic-access-token', refresh_token: 'synthetic-refresh-token')
30
+ expect(File.binread(path)).to start_with(PWN::Plugins::Vault::YUBIKEY_HEADER)
31
+ ensure
32
+ $stdin = previous_input
33
+ $stdout = previous_output
34
+ end
35
+ end
36
+ end
37
+ end
@@ -16,10 +16,9 @@ RSpec.describe 'PWN::AI::CLI' do
16
16
  session = PWN::Sessions.create(source: 'fixture')
17
17
  PWN::AI::Agent::OpenGoal.begin!(request: 'Original objective', session_id: session[:id])
18
18
  expect(PWN::Sessions).not_to receive(:create)
19
- expect(PWN::AI::Agent::Loop).to receive(:run) do |opts|
19
+ expect(PWN::AI::CLI).to receive(:mission_request) do |opts|
20
20
  expect(opts).to include(session_id: session[:id], request: 'continue')
21
- opts[:on_outcome].call(status: 'blocked')
22
- 'Not complete'
21
+ { outcome: { status: 'blocked' }, final: 'Not complete' }
23
22
  end
24
23
  expect(PWN::AI::CLI.run(argv: ['--resume-session', session[:id]], output: StringIO.new)).to eq(1)
25
24
  end
@@ -33,10 +32,9 @@ RSpec.describe 'PWN::AI::CLI' do
33
32
  File.write(path, JSON.generate(contract))
34
33
  argv = ['--replace-session', session[:id], '--ai', 'New objective', '--verification-contract', path]
35
34
  expect { PWN::AI::CLI.parse(argv: argv) }.to raise_error(OptionParser::InvalidArgument, /approve-replacement/)
36
- expect(PWN::AI::Agent::Loop).to receive(:run) do |opts|
35
+ expect(PWN::AI::CLI).to receive(:mission_request) do |opts|
37
36
  expect(opts).to include(session_id: session[:id], request: 'New objective', verification_contract: contract)
38
- opts[:on_outcome].call(status: 'complete')
39
- 'fixture'
37
+ { outcome: { status: 'complete' }, final: 'fixture' }
40
38
  end
41
39
  expect(PWN::AI::CLI.run(argv: argv + ['--approve-replacement'], output: StringIO.new)).to eq(0)
42
40
  end
@@ -90,16 +88,11 @@ RSpec.describe 'PWN::AI::CLI' do
90
88
 
91
89
  it 'promotes only with both operator acknowledgements and rolls back using the saved receipt' do
92
90
  Dir.mktmpdir('pwn-cli-policy-', '/tmp') do |root|
93
- output, error, status = Open3.capture3(
94
- { 'HOME' => root, 'LANG' => 'C.UTF-8' }, RbConfig.ruby,
95
- File.expand_path('../../../../scripts/benchmark_policy.rb', __dir__),
96
- '--heldout', '--snapshot-dir', File.join(root, 'snapshots'), unsetenv_others: true
97
- )
98
- expect(status.success?).to be(true), error
91
+ report = PWN::AI::Agent::PolicyEvaluation.export_training(dir: File.join(root, 'snapshots'))
99
92
  baseline = File.join(root, 'snapshots', 'off.json')
100
93
  candidate = File.join(root, 'snapshots', 'on.json')
101
94
  reports = File.join(root, 'reports.json')
102
- File.write(reports, JSON.generate(JSON.parse(output).fetch('heldout')))
95
+ File.write(reports, JSON.generate(report.fetch(:heldout)))
103
96
  live = File.join(root, 'live.json')
104
97
  FileUtils.cp(baseline, live)
105
98
  argv = ['--policy', 'promote', '--baseline', baseline, '--candidate', candidate, '--reports', reports, '--live-policy', live]
@@ -201,14 +194,13 @@ RSpec.describe 'PWN::AI::CLI' do
201
194
  allow(PWN::Config).to receive(:refresh_env)
202
195
  allow(PWN::AI::Context).to receive(:ingestion_db_path).and_return(File.join(root, 'evidence.db'))
203
196
  allow_any_instance_of(Net::HTTP).to receive(:request).and_raise(Errno::ECONNREFUSED)
204
- expect(PWN::AI::Agent::Loop).to receive(:run) do |args|
197
+ expect(PWN::AI::CLI).to receive(:mission_request) do |args|
205
198
  db = SQLite3::Database.new(File.join(root, 'evidence.db'))
206
199
  expect(db.get_first_value('SELECT count(*) FROM chunks')).to be > 0
207
200
  db.close
208
201
  expect(File.file?(File.join(root, "#{args[:session_id]}.jsonl"))).to be(true)
209
202
  expect(args[:request]).to eq('explain')
210
- args.fetch(:on_outcome).call(status: 'complete', unmet: [])
211
- 'fixture answer'
203
+ { outcome: { status: 'complete', unmet: [] }, final: 'fixture answer' }
212
204
  end
213
205
  output = StringIO.new
214
206
  expect(PWN::AI::CLI.run(argv: ['--analyze', source, '--ai', 'explain'], output: output)).to eq(0)
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe 'OAuth vault persistence' do
7
+ before do
8
+ @home = Dir.mktmpdir('pwn-oauth-vault-')
9
+ allow(Dir).to receive(:home).and_return(@home)
10
+ @path = File.join(@home, 'custom-vault')
11
+ @decryptor = File.join(@home, 'independent-decryptor')
12
+ stub_const('PWN::Env', { driver_opts: { pwn_env_path: @path, pwn_dec_path: @decryptor }, ai: {}, plugins: {} })
13
+ @runtime = Thread.current[:pwn_request_runtime]
14
+ Thread.current[:pwn_request_runtime] = nil
15
+ @cfg = { unrelated: 'keep', ai: {}, plugins: {} }
16
+ @key = Base64.strict_encode64('k' * 32)
17
+ @iv = Base64.strict_encode64('i' * 16)
18
+ allow(ENV).to receive(:fetch).and_call_original
19
+ allow(ENV).to receive(:fetch).with('PWN_DECRYPTOR_KEY', nil).and_return(nil)
20
+ allow(ENV).to receive(:fetch).with('PWN_DECRYPTOR_IV', nil).and_return(nil)
21
+ @selector = { yubikey: { slot: 2, serial: '123456' } }
22
+ @device = double('offline YubiKey')
23
+ stub_const('PWN::Plugins::YubiKey', @device)
24
+ allow(@device).to receive(:challenge_response) do |opts|
25
+ expect(opts).to include(@selector[:yubikey])
26
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
27
+ end
28
+ File.write(@path, YAML.dump(@cfg))
29
+ PWN::Plugins::Vault.encrypt(file: @path, key: @key, iv: @iv)
30
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
31
+ end
32
+
33
+ after do
34
+ Thread.current[:pwn_request_runtime] = @runtime
35
+ FileUtils.remove_entry(@home)
36
+ end
37
+
38
+ {
39
+ PWN::AI::OpenAI => %i[ai openai],
40
+ PWN::AI::Anthropic => %i[ai anthropic],
41
+ PWN::AI::Grok => %i[ai grok],
42
+ PWN::Plugins::GoogleWorkspace => %i[plugins google_workspace]
43
+ }.each do |provider, config_path|
44
+ describe provider do
45
+ before do
46
+ @cfg[config_path.first][config_path.last] = { model: 'fixture-model', oauth: { refresh_token: 'previous-refresh' } }
47
+ end
48
+
49
+ it 'merges OAuth fields through the shared updater without requiring a sidecar' do
50
+ oauth = { bearer_token: 'rotated-access', refresh_token: 'rotated-refresh', expires_at: 123, ignored: 'do-not-persist' }
51
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
52
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt)
53
+ expect(PWN::Plugins::Vault).to receive(:update).with(file: @path, decryptor_file: @decryptor).and_yield(@cfg).and_return(:saved)
54
+
55
+ expect(described_class.send(:persist_oauth_to_vault, oauth: oauth)).to be(true)
56
+ expect(@cfg.dig(*config_path, :oauth)).to eq(oauth.except(:ignored))
57
+ expect(@cfg.dig(*config_path, :model)).to eq('fixture-model')
58
+ expect(@cfg[:unrelated]).to eq('keep')
59
+ expect(File).not_to exist(@decryptor)
60
+ end
61
+
62
+ %i[legacy hardware].each do |format|
63
+ it "preserves #{format} credentials and field merge semantics with ciphertext-only staging" do
64
+ access = format == :hardware ? @selector : { key: @key, iv: @iv }
65
+ File.write(@decryptor, YAML.dump(key: @key, iv: @iv)) if format == :legacy
66
+ @cfg.dig(*config_path, :oauth).merge!(expires_at: 42, keep: 'unchanged')
67
+ File.write(@path, YAML.dump(@cfg))
68
+ PWN::Plugins::Vault.encrypt({ file: @path }.merge(access))
69
+ before = File.binread(@path)
70
+ sidecar_before = File.binread(@decryptor) if format == :legacy
71
+ oauth = { bearer_token: 'rotated-access', refresh_token: nil, expires_at: nil,
72
+ id_token: 'rotated-id', account_id: 'rotated-account', client_id: 'fixture-client-id',
73
+ client_secret: 'fixture-client-secret', redirect_uri: 'https://example.invalid/callback', scope: 'fixture-scope', ignored: 'ignored' }
74
+ accepted = %i[bearer_token]
75
+ accepted += %i[id_token account_id] if config_path.last == :openai
76
+ accepted += %i[client_id client_secret redirect_uri scope] if config_path.last == :google_workspace
77
+ expected = Marshal.load(Marshal.dump(@cfg))
78
+ expected.dig(*config_path, :oauth).merge!(oauth.slice(*accepted))
79
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
80
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt)
81
+ expect(File).not_to receive(:write)
82
+ expect(Tempfile).to receive(:create).and_wrap_original do |create, *args, &block|
83
+ create.call(*args) do |temp|
84
+ expect(temp).to receive(:write).and_wrap_original do |write, bytes|
85
+ plaintext = PWN::Plugins::Vault.decrypt_data(access.merge(data: bytes))
86
+ expect(YAML.safe_load(plaintext, permitted_classes: [Symbol], symbolize_names: true)).to eq(expected)
87
+ expect(bytes).not_to include('rotated-access', 'fixture-client-secret')
88
+ expect(File.binread(@path)).to eq(before)
89
+ expect(temp.stat.mode & 0o777).to eq(0o600)
90
+ write.call(bytes)
91
+ end
92
+ block.call(temp)
93
+ end
94
+ end
95
+
96
+ expect(described_class.send(:persist_oauth_to_vault, oauth: oauth)).to be(true)
97
+ expect(PWN::Plugins::Vault.dump({ file: @path }.merge(access))).to eq(expected)
98
+ expect(File.stat(@path).mode & 0o777).to eq(0o600)
99
+ if format == :legacy
100
+ expect(File.binread(@decryptor)).to eq(sidecar_before)
101
+ expect(Dir.children(@home)).to match_array(%w[custom-vault independent-decryptor])
102
+ else
103
+ expect(File.binread(@path)).to start_with(PWN::Plugins::Vault::YUBIKEY_HEADER)
104
+ expect(Dir.children(@home)).to eq(['custom-vault'])
105
+ end
106
+ end
107
+ end
108
+
109
+ it 'fails closed without prompting when legacy credentials are missing' do
110
+ before = File.binread(@path)
111
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt_data)
112
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt_data)
113
+ expect(described_class.send(:persist_oauth_to_vault, oauth: { bearer_token: 'rotated-access' })).to be(false)
114
+ expect(File.binread(@path)).to eq(before)
115
+ expect(Dir.children(@home)).to eq(['custom-vault'])
116
+ end
117
+
118
+ it 'keeps ciphertext unchanged and excludes secret-bearing exception text on encryption failure' do
119
+ File.write(@decryptor, YAML.dump(key: @key, iv: @iv))
120
+ before = File.binread(@path)
121
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic-private-error')
122
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
123
+ expect(File).not_to receive(:write)
124
+ expect(Tempfile).not_to receive(:create)
125
+ result = nil
126
+
127
+ expect do
128
+ result = described_class.send(:persist_oauth_to_vault, oauth: { bearer_token: 'rotated-access' })
129
+ end.to output(/\A\[!\] .* OAuth vault persistence failed \(session tokens still updated\): IOError\n\z/).to_stderr
130
+ expect(result).to be(false)
131
+ expect(File.binread(@path)).to eq(before)
132
+ expect(Dir.children(@home)).to match_array(%w[custom-vault independent-decryptor])
133
+ end
134
+
135
+ it 'uses the default vault path with hardware metadata and no sidecar' do
136
+ default_path = File.join(@home, '.pwn', 'pwn.yaml')
137
+ FileUtils.mkdir_p(File.dirname(default_path))
138
+ File.write(default_path, YAML.dump(@cfg))
139
+ PWN::Plugins::Vault.encrypt({ file: default_path }.merge(@selector))
140
+ custom_before = File.binread(@path)
141
+ PWN::Env[:driver_opts] = {}
142
+
143
+ expect(described_class.send(:persist_oauth_to_vault, oauth: { bearer_token: 'rotated-access' })).to be(true)
144
+ expect(PWN::Plugins::Vault.dump(file: default_path).dig(*config_path, :oauth, :bearer_token)).to eq('rotated-access')
145
+ expect(File.binread(@path)).to eq(custom_before)
146
+ expect(Dir.children(File.dirname(default_path))).to eq(['pwn.yaml'])
147
+ end
148
+
149
+ it 'leaves hardware ciphertext unchanged when the device disappears after decryption' do
150
+ File.write(@path, YAML.dump(@cfg))
151
+ PWN::Plugins::Vault.encrypt({ file: @path }.merge(@selector))
152
+ before = File.binread(@path)
153
+ calls = 0
154
+ allow(@device).to receive(:challenge_response) do |opts|
155
+ calls += 1
156
+ raise IOError, 'synthetic-private-error' if calls > 1
157
+
158
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
159
+ end
160
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
161
+ expect(File).not_to receive(:write)
162
+ expect(Tempfile).not_to receive(:create)
163
+ result = nil
164
+
165
+ expect do
166
+ result = described_class.send(:persist_oauth_to_vault, oauth: { bearer_token: 'rotated-access' })
167
+ end.to output(/\A\[!\] .* OAuth vault persistence failed \(session tokens still updated\): IOError\n\z/).to_stderr
168
+ expect(result).to be(false)
169
+ expect(calls).to eq(2)
170
+ expect(File.binread(@path)).to eq(before)
171
+ expect(Dir.children(@home)).to eq(['custom-vault'])
172
+ end
173
+
174
+ it 'persists a rotated provider refresh token during a request runtime to a hardware vault' do
175
+ File.write(@path, YAML.dump(@cfg))
176
+ PWN::Plugins::Vault.encrypt({ file: @path }.merge(@selector))
177
+ runtime = double('request runtime')
178
+ Thread.current[:pwn_request_runtime] = runtime
179
+ expect(runtime).to receive(:update_provider_oauth).with(config_path.last, hash_including(bearer_token: 'rotated-access', refresh_token: 'rotated-refresh')).and_return(:updated) if config_path.first == :ai
180
+ allow(RestClient).to receive(:post).and_return(double(body: { access_token: 'rotated-access', refresh_token: 'rotated-refresh', expires_in: 3600 }.to_json))
181
+ expect(PWN::Plugins::Vault).not_to receive(:decrypt)
182
+ expect(PWN::Plugins::Vault).not_to receive(:encrypt)
183
+
184
+ expect(described_class.refresh_oauth_bearer_token(refresh_token: 'previous-refresh', client_id: 'fixture-id', client_secret: 'fixture-secret')).to eq('rotated-access')
185
+ persisted = PWN::Plugins::Vault.dump(file: @path)
186
+ expect(persisted.dig(*config_path, :oauth)).to include(bearer_token: 'rotated-access', refresh_token: 'rotated-refresh')
187
+ expect(persisted.dig(*config_path, :model)).to eq('fixture-model')
188
+ expect(persisted[:unrelated]).to eq('keep')
189
+ expect(File.binread(@path)).to start_with(PWN::Plugins::Vault::YUBIKEY_HEADER)
190
+ expect(File.stat(@path).mode & 0o777).to eq(0o600)
191
+ expect(Dir.children(@home)).to eq(['custom-vault'])
192
+ end
193
+ end
194
+ end
195
+ end
@@ -65,14 +65,14 @@ describe PWN::AI::OpenAI do
65
65
  end
66
66
 
67
67
  it 'leaves the original vault encrypted and unchanged if re-encryption fails' do
68
- allow(PWN::Plugins::Vault).to receive(:encrypt).and_raise(IOError, 'fixture write failure')
68
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'fixture write failure')
69
69
  expect(described_class.send(:persist_oauth_to_vault, oauth: { bearer_token: 'fixture-access' })).to be(false)
70
70
  expect(File.binread(@vault_path)).to eq(@original_ciphertext)
71
71
  expect(Dir.children(@oauth_home)).to match_array(%w[pwn.yaml custom.decryptor])
72
72
  end
73
73
 
74
74
  it 'does not expose vault contents through persistence exception messages' do
75
- allow(PWN::Plugins::Vault).to receive(:encrypt).and_raise(IOError, 'fixture-access in parser context')
75
+ allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'fixture-access in parser context')
76
76
  expect do
77
77
  described_class.send(:persist_oauth_to_vault, oauth: { bearer_token: 'fixture-access' })
78
78
  end.to output(/vault persistence failed.*IOError/).to_stderr
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Open WebUI workspace tools' do
6
+ before do
7
+ Thread.current[:pwn_openwebui_workspace_tools] = nil
8
+ allow(PWN::AI::HttpRetry).to receive(:provider_config).with(engine: :openwebui).and_return(
9
+ { model: 'llama', temp: 1, max_prompt_length: 10_000, system_role_content: 'sys' }
10
+ )
11
+ allow(PWN::AI::OpenWebUI).to receive(:strict_system_template?).and_return(false)
12
+ allow(PWN::AI::OpenWebUI).to receive(:workspace_tools).and_return([{ id: 'ask_jev', name: 'Ask Jev' }])
13
+ end
14
+
15
+ it 'asks the server to run Ask Jev and does not send client tools' do
16
+ captured = nil
17
+ allow(PWN::AI::OpenWebUI).to receive(:openwebui_rest_call) do |opts|
18
+ captured = opts[:http_body]
19
+ { choices: [{ message: { role: 'assistant', content: '0.82 yes' } }], sources: [] }.to_json
20
+ end
21
+
22
+ result = PWN::AI::OpenWebUI.chat(request: 'Is the login bypassed?', server_tools: ['Ask Jev'], spinner: false)
23
+
24
+ expect(captured[:tool_ids]).to eq(['ask_jev'])
25
+ expect(captured[:params]).to eq({ function_calling: 'legacy' })
26
+ expect(captured[:stream]).to be(false)
27
+ expect(captured).not_to have_key(:tools)
28
+ expect(result[:choices].last[:content]).to eq('0.82 yes')
29
+ end
30
+
31
+ it 'does not run workspace tools on the proxied Ollama chat path' do
32
+ expect do
33
+ PWN::AI::OpenWebUI.chat_with_tools(messages: [{ role: 'user', content: 'hi' }], server_tools: ['Ask Jev'])
34
+ end.to raise_error(ArgumentError, /proxied Ollama/)
35
+ end
36
+ end
@@ -25,7 +25,7 @@ describe PWN::AI::Router do
25
25
  it "routes session summaries only to the active #{engine} model" do
26
26
  stub_const('PWN::Env', { ai: { active: engine.to_sym, engine.to_sym => { model: 'selected-model', key: 'fixture-key', base_uri: 'http://localhost:1234' } } })
27
27
  mod = Object.const_get(described_class::ENGINE_MOD.fetch(engine))
28
- expect(mod).to receive(:chat).with(hash_including(model: 'selected-model', quiet: true, spinner: false)).and_return('{"title":"Topic","summary":"Overall conversation context."}')
28
+ expect(mod).to receive(:chat).with(hash_including(model: 'selected-model', quiet: true, spinner: false, timeout: 90, reasoning_effort: 'none')).and_return('{"title":"Topic","summary":"Overall conversation context."}')
29
29
  expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to include('Topic')
30
30
  expect(PWN::Env[:ai][:active]).to eq(engine.to_sym)
31
31
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::AI::ServerTools do
6
+ it 'does not invent a workspace for Ollama' do
7
+ expect(described_class.catalog(engine: :ollama)).to include(workspace: false, tools: [])
8
+ expect(described_class.call(name: 'web_search', request: 'today', engine: :ollama)).to include(available: false)
9
+ end
10
+
11
+ it 'keeps Ask Jev on Open WebUI when another model is active' do
12
+ allow(PWN::Env).to receive(:dig).and_call_original
13
+ allow(PWN::Env).to receive(:dig).with(:ai, :openwebui).and_return({ base_uri: 'https://ow.example', key: 'token' })
14
+ allow(PWN::Env).to receive(:dig).with(:ai, :active).and_return(:grok)
15
+ allow(PWN::AI::OpenWebUI).to receive(:workspace_tools).and_return([{ id: 'ask_jev', name: 'Ask Jev' }])
16
+ allow(PWN::AI::OpenWebUI).to receive(:chat).and_return({ choices: [{ role: 'assistant', content: 'yes 0.8' }], sources: [{ name: 'Ask Jev' }] })
17
+
18
+ result = described_class.call(name: 'Ask Jev', request: 'Is the login bypassed?', engine: :grok)
19
+
20
+ expect(result).to include(available: true, engine: :openwebui, content: 'yes 0.8')
21
+ expect(PWN::AI::OpenWebUI).to have_received(:chat).with(hash_including(server_tools: ['Ask Jev'], request: 'Is the login bypassed?'))
22
+ end
23
+
24
+ it 'appends a hosted tool beside client function tools' do
25
+ body = described_class.attach(
26
+ engine: :anthropic,
27
+ http_body: { tools: [{ name: 'shell', input_schema: { type: 'object' } }] },
28
+ request: { server_tools: ['web_search'] }
29
+ )
30
+ expect(body[:tools].last).to include(type: 'web_search_20250305', name: 'web_search')
31
+ expect(body[:tools].first[:name]).to eq('shell')
32
+ end
33
+
34
+ it 'refuses to mix OpenAI hosted tools into a chat-completions body' do
35
+ expect do
36
+ described_class.attach(engine: :openai, http_body: { messages: [] }, request: { server_tools: ['web_search'] })
37
+ end.to raise_error(ArgumentError, /Responses API/)
38
+ end
39
+ end