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
@@ -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
@@ -201,14 +199,13 @@ RSpec.describe 'PWN::AI::CLI' do
201
199
  allow(PWN::Config).to receive(:refresh_env)
202
200
  allow(PWN::AI::Context).to receive(:ingestion_db_path).and_return(File.join(root, 'evidence.db'))
203
201
  allow_any_instance_of(Net::HTTP).to receive(:request).and_raise(Errno::ECONNREFUSED)
204
- expect(PWN::AI::Agent::Loop).to receive(:run) do |args|
202
+ expect(PWN::AI::CLI).to receive(:mission_request) do |args|
205
203
  db = SQLite3::Database.new(File.join(root, 'evidence.db'))
206
204
  expect(db.get_first_value('SELECT count(*) FROM chunks')).to be > 0
207
205
  db.close
208
206
  expect(File.file?(File.join(root, "#{args[:session_id]}.jsonl"))).to be(true)
209
207
  expect(args[:request]).to eq('explain')
210
- args.fetch(:on_outcome).call(status: 'complete', unmet: [])
211
- 'fixture answer'
208
+ { outcome: { status: 'complete', unmet: [] }, final: 'fixture answer' }
212
209
  end
213
210
  output = StringIO.new
214
211
  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
@@ -37,7 +37,7 @@ describe PWN::AI::Router do
37
37
  expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
38
38
  expect(PWN::AI::OpenAI).not_to receive(:obtain_oauth_bearer_token)
39
39
  allow(PWN::AI::OpenAI).to receive(:chat).and_raise(IOError, 'offline provider')
40
- expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to eq('')
40
+ expect { described_class.summarize(text: 'Conversation', llm: true, session: true) }.to raise_error(IOError, 'offline provider')
41
41
  expect(Thread.current[:pwn_session_title]).to be_nil
42
42
  PWN::Env[:ai][:openai].delete(:key)
43
43
  expect(PWN::AI::OpenAI).not_to receive(:chat)
@@ -52,11 +52,39 @@ describe PWN::AI::Router do
52
52
  expect(mod).not_to receive(:obtain_oauth_bearer_token)
53
53
  expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
54
54
  expect(PWN::Plugins::TransparentBrowser).not_to receive(:open)
55
- expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to eq('')
55
+ if engine == 'anthropic'
56
+ # This adapter parses a nil transport result; preserve that failure
57
+ # rather than disguising it as a downstream title JSON parse error.
58
+ expect { described_class.summarize(text: 'Conversation', llm: true, session: true) }.to raise_error(TypeError)
59
+ else
60
+ expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to eq('')
61
+ end
56
62
  expect(Thread.current[:pwn_session_title]).to be_nil
57
63
  end
58
64
  end
59
65
 
66
+ it 'extracts supported symbol and string keyed wrapper content without reasoning or tool data' do
67
+ stub_const('PWN::Env', { ai: { active: :openai, openai: { model: 'fixture', key: 'fixture-key' } } })
68
+ body = JSON.generate(title: 'Certificate repair', summary: 'Repaired login certificates.')
69
+ payloads = [
70
+ { choices: [{ message: { role: 'assistant', content: body } }] },
71
+ { 'choices' => [{ 'role' => 'assistant', 'content' => body }] },
72
+ { 'content' => [{ 'type' => 'thinking', 'thinking' => 'not output' }, { 'type' => 'text', 'text' => body }] },
73
+ { choices: [{ content: [{ type: 'output_text', text: body }] }] },
74
+ { message: { role: 'assistant', content: body } },
75
+ { title: 'Certificate repair', summary: 'Repaired login certificates.' }
76
+ ]
77
+ payloads.each do |payload|
78
+ allow(PWN::AI::OpenAI).to receive(:chat).and_return(payload)
79
+ expect(described_class.summarize(text: 'Fixture', llm: true, session: true)).to eq(body)
80
+ end
81
+ [{ content: [{ type: 'tool_use', input: { title: 'Wrong', summary: 'Wrong' } }] },
82
+ { choices: [{ role: 'user', content: body }] }, { content: { title: 'Wrong' } }].each do |payload|
83
+ allow(PWN::AI::OpenAI).to receive(:chat).and_return(payload)
84
+ expect(described_class.summarize(text: 'Fixture', llm: true, session: true)).to eq('')
85
+ end
86
+ end
87
+
60
88
  it 'should display information for authors' do
61
89
  expect(described_class).to respond_to :authors
62
90
  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