pwn 0.5.739 → 0.5.741

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Agent-Tool-Registry.md +163 -6
  4. data/documentation/Diagrams.md +1 -1
  5. data/documentation/Home.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +6 -2
  8. data/documentation/Recon-Findings-API.md +33 -1
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-ai-Agent.md +1 -1
  11. data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
  12. data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
  13. data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
  14. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
  19. data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
  20. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
  21. data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
  22. data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
  23. data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
  24. data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
  25. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
  26. data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
  27. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
  33. data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
  34. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  35. data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
  36. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
  37. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
  38. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  39. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
  40. data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
  41. data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
  44. data/etc/default_skills/pwn/reports/SKILL.md +2 -0
  45. data/lib/pwn/ai/agent/confirmation.rb +163 -0
  46. data/lib/pwn/ai/agent/dispatch.rb +43 -5
  47. data/lib/pwn/ai/agent/loop.rb +45 -11
  48. data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
  49. data/lib/pwn/ai/agent/registry.rb +2 -1
  50. data/lib/pwn/ai/agent/result.rb +76 -6
  51. data/lib/pwn/ai/agent/task_dag.rb +220 -0
  52. data/lib/pwn/ai/agent/tool_guard.rb +166 -24
  53. data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
  54. data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
  55. data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
  56. data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
  57. data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
  58. data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
  59. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
  60. data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
  61. data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
  62. data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
  63. data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
  64. data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
  65. data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
  66. data/lib/pwn/ai/agent/tools/shell.rb +22 -10
  67. data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
  68. data/lib/pwn/ai/agent.rb +3 -1
  69. data/lib/pwn/ai/cli.rb +26 -2
  70. data/lib/pwn/ai/context.rb +4 -2
  71. data/lib/pwn/ai/router.rb +196 -0
  72. data/lib/pwn/ai/tools/README.md +5 -0
  73. data/lib/pwn/ai.rb +1 -0
  74. data/lib/pwn/config.rb +10 -0
  75. data/lib/pwn/engagement.rb +45 -1
  76. data/lib/pwn/ffi/capstone.rb +120 -0
  77. data/lib/pwn/ffi/keystone.rb +107 -0
  78. data/lib/pwn/ffi.rb +2 -0
  79. data/lib/pwn/migrate.rb +5 -1
  80. data/lib/pwn/plugins/aflplusplus.rb +238 -13
  81. data/lib/pwn/plugins/ai_sandbox.rb +240 -0
  82. data/lib/pwn/plugins/artifact_registry.rb +188 -22
  83. data/lib/pwn/plugins/assembly.rb +73 -2
  84. data/lib/pwn/plugins/basic_auth.rb +17 -4
  85. data/lib/pwn/plugins/binary_parser.rb +439 -38
  86. data/lib/pwn/plugins/exploit_dev.rb +83 -14
  87. data/lib/pwn/plugins/findings.rb +208 -42
  88. data/lib/pwn/plugins/gdbmi.rb +265 -38
  89. data/lib/pwn/plugins/httpx.rb +93 -0
  90. data/lib/pwn/plugins/jobs.rb +260 -102
  91. data/lib/pwn/plugins/method_catalog.rb +328 -0
  92. data/lib/pwn/plugins/nmap_it.rb +157 -26
  93. data/lib/pwn/plugins/nuclei.rb +122 -29
  94. data/lib/pwn/plugins/preflight_checker.rb +3 -2
  95. data/lib/pwn/plugins/process_tube.rb +51 -2
  96. data/lib/pwn/plugins/radare2.rb +120 -26
  97. data/lib/pwn/plugins/recon.rb +33 -0
  98. data/lib/pwn/plugins/repl/mesh.rb +203 -13
  99. data/lib/pwn/plugins/rop.rb +121 -31
  100. data/lib/pwn/plugins/transparent_browser.rb +212 -14
  101. data/lib/pwn/plugins/vault.rb +182 -24
  102. data/lib/pwn/plugins.rb +4 -0
  103. data/lib/pwn/reports/engagement.rb +8 -2
  104. data/lib/pwn/reports/html.rb +28 -9
  105. data/lib/pwn/reports/json.rb +2 -1
  106. data/lib/pwn/reports/markdown.rb +49 -12
  107. data/lib/pwn/reports/sarif.rb +17 -3
  108. data/lib/pwn/reports.rb +184 -34
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/integration/reports_spec.rb +93 -0
  111. data/spec/integration/result_condition_spec.rb +11 -8
  112. data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
  113. data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
  114. data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
  115. data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
  116. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  117. data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
  118. data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
  119. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  120. data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
  121. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
  122. data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
  123. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
  124. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
  125. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
  126. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
  127. data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
  128. data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
  129. data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
  130. data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
  131. data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
  132. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
  133. data/spec/lib/pwn/ai/cli_spec.rb +12 -1
  134. data/spec/lib/pwn/ai/router_spec.rb +57 -0
  135. data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
  136. data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
  137. data/spec/lib/pwn/migrate_spec.rb +30 -2
  138. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
  139. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
  140. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
  141. data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
  142. data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
  143. data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
  144. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
  145. data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
  146. data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
  147. data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
  148. data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
  149. data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
  150. data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
  151. data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
  152. data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
  153. data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
  154. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
  155. data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
  156. data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
  157. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
  158. data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
  159. data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
  160. data/spec/lib/pwn/reports_spec.rb +196 -0
  161. data/spec/spec_helper.rb +3 -0
  162. data/third_party/pwn_rdoc.jsonl +230 -4
  163. metadata +47 -5
@@ -12,9 +12,38 @@ describe 'PWN::AI::Agent::Tools finding_record' do
12
12
  handler = PWN::AI::Agent::Registry.lookup(name: 'finding_record').handler
13
13
  expect { handler.call('title' => 'fixture', 'poc_artifacts' => ['/tmp/legacy']) }.to raise_error(ArgumentError)
14
14
  fields = PWN::AI::Agent::Registry.lookup(name: 'finding_record').schema[:parameters][:properties]
15
- expect(fields.keys).to include(:cwe, :cvss_score, :cvss_vector, :evidence_paths, :attack_chain_refs, :remediation, :confidence)
15
+ expect(fields.keys).to include(:cwe, :cvss_score, :cvss_vector, :evidence_paths, :artifact_handles, :severity_justification, :reproduction_steps, :attack_chain_refs, :remediation, :confidence)
16
16
  contract = PWN::AI::Agent::Registry.lookup(name: 'finding_record').schema[:parameters][:anyOf]
17
- expect(contract.last[:required]).to include('poc', 'cwe', 'cvss_score', 'evidence_paths')
17
+ expect(contract.last[:required]).to include('poc', 'cwe', 'cvss_score', 'severity_justification', 'reproduction_steps')
18
+ end
19
+
20
+ it 'accepts handle-only findings through the registered schema and handler' do
21
+ Dir.mktmpdir do |dir|
22
+ allow(Dir).to receive(:home).and_return(dir)
23
+ stub_const('PWN::Plugins::Findings::FILE', File.join(dir, 'findings.jsonl'))
24
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
25
+ artifact = PWN::Plugins::ArtifactRegistry.spill(bytes: 'puts :fixture', session_id: 'fixture')
26
+ args = { title: 'Fixture exposure', cwe: 'CWE-200', cvss_vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N',
27
+ cvss_score: 5.3, affected_asset: 'local fixture', poc: 'ruby poc.rb', attack_chain_refs: [],
28
+ remediation: 'Restrict access.', confidence: 0.9, artifact_handles: [{ handle: artifact[:handle], kind: 'poc', label: 'poc.rb' }],
29
+ severity_justification: 'Read-only data exposure; no availability impact demonstrated.',
30
+ reproduction_steps: ['Start the fixture.', 'Run ruby poc.rb and inspect its output.'] }
31
+ tool = PWN::AI::Agent::Registry.lookup(name: 'finding_record')
32
+ schema = JSONSchemer.schema(JSON.parse(JSON.generate(tool.schema[:parameters])))
33
+ expect(schema.valid?(JSON.parse(JSON.generate(args)))).to be(true)
34
+ row = tool.handler.call(JSON.parse(JSON.generate(args)))
35
+ expect(row[:evidence_artifacts].first).to include(handle: artifact[:handle], sha256: artifact[:sha256])
36
+ expect(row[:reproduction_steps]).to eq(args[:reproduction_steps])
37
+ dispatched = JSON.parse(PWN::AI::Agent::Dispatch.call(
38
+ tool_call: { function: { name: 'finding_record', arguments: JSON.generate(args) } },
39
+ scope_path: File.join(dir, 'absent.yaml'), session_id: 'fixture'
40
+ ), symbolize_names: true)
41
+ expect(dispatched.dig(:result, :evidence_artifacts, 0)).to include(handle: artifact[:handle], sha256: artifact[:sha256])
42
+ %i[severity_justification reproduction_steps].each do |key|
43
+ expect(schema.valid?(JSON.parse(JSON.generate(args.except(key))))).to be(false)
44
+ expect { tool.handler.call(args.except(key)) }.to raise_error(ArgumentError, /#{key}/)
45
+ end
46
+ end
18
47
  end
19
48
 
20
49
  it 'registers expected tool names' do
@@ -22,8 +51,23 @@ describe 'PWN::AI::Agent::Tools finding_record' do
22
51
  expect(PWN::AI::Agent::Registry.lookup(name: 'finding_report')).not_to be_nil
23
52
  end
24
53
 
54
+ it 'exposes directed enables links and existing-finding link updates' do
55
+ schema = PWN::AI::Agent::Registry.lookup(name: 'finding_record').schema[:parameters]
56
+ expect(schema[:properties][:enables]).to include(type: 'array', uniqueItems: true)
57
+ validator = JSONSchemer.schema(JSON.parse(JSON.generate(schema)))
58
+ expect(validator.valid?({ 'op' => 'link', 'id' => 'ssrf', 'enables' => ['admin'] })).to be(true)
59
+ expect(validator.valid?({ 'op' => 'link', 'id' => 'ssrf' })).to be(false)
60
+ expect(validator.valid?({ 'op' => 'link', 'id' => 'ssrf', 'enables' => %w[admin admin] })).to be(false)
61
+ end
62
+
25
63
  it 'exposes verify retest and chain_impact operations' do
26
64
  fields = PWN::AI::Agent::Registry.lookup(name: 'finding_record').schema[:parameters][:properties]
27
65
  expect(fields[:op][:enum]).to include('verify', 'retest', 'chain_impact', 'gaps')
66
+ schema = JSONSchemer.schema(JSON.parse(JSON.generate(PWN::AI::Agent::Registry.lookup(name: 'finding_record').schema[:parameters])))
67
+ %w[verify retest].each do |op|
68
+ expect(schema.valid?({ 'op' => op, 'id' => 'fixture', 'kind' => 'script', 'execution_log' => '/tmp/output.log', 'impact' => 'fixture impact' })).to be(true)
69
+ end
70
+ expect(schema.valid?({ 'op' => 'gaps' })).to be(true)
71
+ expect(schema.valid?({ 'op' => 'chain_impact', 'ids' => %w[parent child], 'combined_impact_path' => '/tmp/chain.txt' })).to be(true)
28
72
  end
29
73
  end
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'shellwords'
5
+ require 'json'
6
+ require 'tmpdir'
7
+ require 'fileutils'
8
+ require 'base64'
4
9
 
5
10
  describe 'PWN::AI::Agent::Tools fuzz_campaign' do
6
11
  before(:all) do
@@ -11,4 +16,45 @@ describe 'PWN::AI::Agent::Tools fuzz_campaign' do
11
16
  it 'registers expected tool names' do
12
17
  expect(PWN::AI::Agent::Registry.lookup(name: 'fuzz_campaign')).not_to be_nil
13
18
  end
19
+
20
+ it 'starts AFL with a durable six-hour supervisor and correctly quoted paths' do
21
+ expect(PWN::Plugins::Jobs).to receive(:start).with(hash_including(max_runtime: 21_600, idempotency_key: 'afl-one', session_id: 'session-one')) do |opts|
22
+ argv = Shellwords.split(opts[:command])
23
+ expect(argv.any? { |part| part.include?('PWN::Plugins::AFLplusplus.campaign') }).to eq(true)
24
+ payload = JSON.parse(Base64.decode64(argv.last), symbolize_names: true)
25
+ expect(payload[:in_dir]).to eq('/tmp/input dir')
26
+ expect(payload[:out_dir]).to eq('/tmp/output dir')
27
+ expect(payload[:target]).to include('/tmp/target program')
28
+ { id: '012345abcdef' }
29
+ end
30
+ tool = PWN::AI::Agent::Registry.lookup(name: 'fuzz_campaign')
31
+ expect(tool.handler.call(action: 'start', in_dir: '/tmp/input dir', out_dir: '/tmp/output dir', target: '"/tmp/target program" @@', max_runtime: 21_600, session_id: 'session-one', idempotency_key: 'afl-one')).to include(id: '012345abcdef')
32
+ end
33
+
34
+ it 'status auto-triages unique crashes without a second tool call' do
35
+ Dir.mktmpdir('pwn-fuzz-status-') do |dir|
36
+ crashes = File.join(dir, 'crashes')
37
+ FileUtils.mkdir_p(crashes)
38
+ File.binwrite(File.join(crashes, 'id:000000,sig:11'), 'CRASH')
39
+ allow(PWN::Plugins::Jobs).to receive(:status).and_return(id: 'job1', status: 'COMPLETED')
40
+ allow(PWN::Plugins::GDBMI).to receive(:run_to_crash).and_return(signal: 'SIGSEGV', pc: '0x1', fault_addr: '0x1', backtrace: ['main'])
41
+ allow(PWN::Plugins::BinaryParser).to receive(:triage).and_return(file_type: 'elf')
42
+ allow(PWN::Plugins::ExploitDev).to receive(:from_crash).and_return(offset: 8)
43
+ tool = PWN::AI::Agent::Registry.lookup(name: 'fuzz_campaign')
44
+ row = tool.handler.call(action: 'status', id: 'job1', out_dir: dir, target: '/bin/true')
45
+ expect(row[:triage][:unique].length).to eq(1)
46
+ expect(row[:triage][:triaged].first).to include(:crash, :backtrace_hash)
47
+ expect(row[:triage][:pipeline]).to eq('pwn-re-003')
48
+ end
49
+ end
50
+
51
+ it 'starts a libFuzzer campaign through the same Jobs supervisor' do
52
+ expect(PWN::Plugins::Jobs).to receive(:start) do |opts|
53
+ payload = JSON.parse(Base64.decode64(Shellwords.split(opts[:command]).last), symbolize_names: true)
54
+ expect(payload[:engine].to_s).to include('libfuzzer')
55
+ { id: 'libf1' }
56
+ end
57
+ tool = PWN::AI::Agent::Registry.lookup(name: 'fuzz_campaign')
58
+ expect(tool.handler.call(action: 'start', engine: 'libfuzzer', in_dir: '/tmp/in', out_dir: '/tmp/out', target: '/tmp/fuzzme')).to include(id: 'libf1')
59
+ end
14
60
  end
@@ -15,4 +15,56 @@ describe 'PWN::AI::Agent::Tools job_run' do
15
15
  expect(PWN::AI::Agent::Registry.lookup(name: 'job_tail')).not_to be_nil
16
16
  expect(PWN::AI::Agent::Registry.lookup(name: 'job_kill')).not_to be_nil
17
17
  end
18
+
19
+ it 'forwards a six-hour runtime independently of dispatch timeout accounting' do
20
+ expect(PWN::Plugins::Jobs).to receive(:start).with(hash_including(command: 'printf scan', max_runtime: 21_600, cwd: '/tmp', idempotency_key: 'campaign-one')).and_return(id: '012345abcdef', status: 'RUNNING')
21
+ ledger = {}
22
+ response = JSON.parse(PWN::AI::Agent::Dispatch.call(
23
+ tool_call: { function: { name: 'job_run', arguments: { command: 'printf scan', max_runtime: 21_600, cwd: '/tmp', idempotency_key: 'campaign-one' } } },
24
+ budget_ledger: ledger
25
+ ))
26
+ expect(response.dig('result', 'id')).to eq('012345abcdef')
27
+ expect(response).not_to have_key('budget')
28
+ expect(ledger).to be_empty
29
+ end
30
+
31
+ it 'tails by byte cursor with a bounded page and lets a new session discover jobs' do
32
+ reader = PWN::AI::Agent::Registry.lookup(name: 'job_tail')
33
+ expect(PWN::Plugins::Jobs).to receive(:tail).with(hash_including(id: '012345abcdef', offset: 17, length: PWN::AI::Agent::Result.page_length)).and_return(next_offset: 23, body: 'output')
34
+ expect(reader.handler.call(id: '012345abcdef', offset: 17, length: 50_000_000)).to include(next_offset: 23)
35
+ expect(PWN::Plugins::Jobs).to receive(:list).with(limit: 20).and_return([{ id: '012345abcdef' }])
36
+ expect(PWN::AI::Agent::Registry.lookup(name: 'job_status').handler.call({})).to include(id: '012345abcdef')
37
+ end
38
+
39
+ it 'routes predicted long shell work before Dispatch injects a blocking timeout' do
40
+ allow(PWN::AI::Agent::ToolGuard).to receive(:auto_job?).with(payload: 'printf slow').and_return(true)
41
+ expect(PWN::Plugins::Jobs).to receive(:start).with(hash_including(command: 'printf slow')).and_return(id: '012345abcdef', status: 'RUNNING')
42
+ ledger = {}
43
+ out = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'shell', arguments: { command: 'printf slow' } } }, budget_ledger: ledger))
44
+ expect(out.dig('result', 'id')).to eq('012345abcdef')
45
+ expect(ledger).to be_empty
46
+ end
47
+
48
+ it 'runs a real serialized launch with environment overrides and incrementally retrieves its result' do
49
+ Dir.mktmpdir('pwn-job-tool-') do |root|
50
+ stub_const('PWN::Plugins::Jobs::JOBS_DIR', root)
51
+ invoke = lambda do |name, args|
52
+ JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: name, arguments: JSON.generate(args) } }), symbolize_names: true)
53
+ end
54
+ launched = invoke.call('job_run', command: 'printf "$PWN_JOB_TEST"', env: { PWN_JOB_TEST: 'supervised-output' }, max_runtime: 5, cwd: root)
55
+ id = launched.fetch(:result).fetch(:id)
56
+ Timeout.timeout(10) do
57
+ loop do
58
+ state = invoke.call('job_status', id: id).fetch(:result)
59
+ break if state[:status] != 'RUNNING'
60
+
61
+ sleep 0.05
62
+ end
63
+ end
64
+ expect(invoke.call('job_status', id: id).fetch(:result)).to include(status: 'COMPLETED', exit_code: 0)
65
+ page = invoke.call('job_tail', id: id, offset: 0).fetch(:result)
66
+ expect(page).to include(body: 'supervised-output', next_offset: 17)
67
+ expect(invoke.call('job_tail', id: id, offset: page[:next_offset]).dig(:result, :bytes)).to eq(0)
68
+ end
69
+ end
18
70
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ describe 'PWN::AI::Agent::Tools loot_query' do
8
+ before(:all) do
9
+ PWN::AI::Agent::Registry.discover(force: true)
10
+ load '/opt/pwn/lib/pwn/ai/agent/tools/loot_query.rb'
11
+ end
12
+
13
+ it 'registers expected tool names' do
14
+ expect(PWN::AI::Agent::Registry.lookup(name: 'loot_query')).not_to be_nil
15
+ end
16
+
17
+ it 'returns recon creds for lateral-movement planning in one Dispatch call' do
18
+ Dir.mktmpdir('pwn-loot-tool-') do |dir|
19
+ allow(Dir).to receive(:home).and_return(dir)
20
+ PWN::Plugins::Vault.store(
21
+ secret: 'hunter2',
22
+ username: 'admin',
23
+ host: 'app.example.test',
24
+ service: 'ssh',
25
+ source: 'recon',
26
+ where: 'banner:22',
27
+ finding_id: 'f00bar',
28
+ engagement: 'lab'
29
+ )
30
+ raw = PWN::AI::Agent::Dispatch.call(
31
+ tool_call: {
32
+ function: {
33
+ name: 'loot_query',
34
+ arguments: JSON.generate(host: 'app.example.test', engagement: 'lab')
35
+ }
36
+ },
37
+ scope_path: File.join(dir, 'absent.yaml')
38
+ )
39
+ row = JSON.parse(raw, symbolize_names: true).fetch(:result)
40
+ hit = Array(row[:records] || row).first
41
+ expect(hit[:username]).to eq('admin')
42
+ expect(hit[:finding_id]).to eq('f00bar')
43
+ expect(hit[:host]).to include('app.example.test')
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ describe 'PWN::AI::Agent::Tools nmap_scan' do
8
+ before(:all) do
9
+ PWN::AI::Agent::Registry.discover(force: true)
10
+ load '/opt/pwn/lib/pwn/ai/agent/tools/nmap_scan.rb'
11
+ end
12
+
13
+ it 'registers expected tool names' do
14
+ expect(PWN::AI::Agent::Registry.lookup(name: 'nmap_scan')).not_to be_nil
15
+ end
16
+
17
+ it 'queries yesterday\'s scan diff in one Dispatch call' do
18
+ expect(PWN::Plugins::NmapIt).to receive(:changes).with(hash_including(since: 'yesterday')).and_return(added_ports: [{ port: 80 }])
19
+ raw = PWN::AI::Agent::Dispatch.call(
20
+ tool_call: { function: { name: 'nmap_scan', arguments: JSON.generate(since: 'yesterday', engagement: 'lab') } },
21
+ scope_path: File.join(Dir.tmpdir, 'absent.yaml')
22
+ )
23
+ row = JSON.parse(raw, symbolize_names: true).fetch(:result)
24
+ expect(row[:added_ports].first[:port]).to eq(80)
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ describe 'PWN::AI::Agent::Tools nuclei_scan' do
8
+ before(:all) do
9
+ PWN::AI::Agent::Registry.discover(force: true)
10
+ load '/opt/pwn/lib/pwn/ai/agent/tools/nuclei_scan.rb'
11
+ end
12
+
13
+ it 'registers expected tool names' do
14
+ expect(PWN::AI::Agent::Registry.lookup(name: 'nuclei_scan')).not_to be_nil
15
+ end
16
+
17
+ it 'records a web finding from JSONL in one Dispatch call' do
18
+ Dir.mktmpdir('pwn-nuclei-tool-') do |dir|
19
+ stub_const('PWN::Plugins::Findings::FILE', File.join(dir, 'findings.jsonl'))
20
+ jsonl = File.join(dir, 'hit.jsonl')
21
+ File.write(jsonl, "#{JSON.generate(
22
+ 'template-id' => 'exposed-panel',
23
+ 'matched-at' => 'https://app.example.test/admin',
24
+ 'info' => { 'name' => 'Exposed admin panel', 'severity' => 'medium' }
25
+ )}\n")
26
+ raw = PWN::AI::Agent::Dispatch.call(
27
+ tool_call: { function: { name: 'nuclei_scan', arguments: JSON.generate(jsonl: jsonl) } },
28
+ scope_path: File.join(dir, 'absent.yaml')
29
+ )
30
+ row = JSON.parse(raw, symbolize_names: true).fetch(:result)
31
+ expect(row[:findings].first[:template_id]).to eq('exposed-panel')
32
+ stored = PWN::Plugins::Findings.report
33
+ expect(stored.first[:matched_at]).to include('/admin')
34
+ expect(stored.first[:severity].to_s).to match(/medium/i)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ describe 'PWN::AI::Agent::Tools pwn_eval plugin schema' do
8
+ before(:all) do
9
+ PWN::AI::Agent::Registry.discover(force: true)
10
+ load '/opt/pwn/lib/pwn/ai/agent/tools/ruby_eval.rb'
11
+ end
12
+
13
+ it 'rejects an unknown plugin keyword before eval and echoes the schema' do
14
+ raw = PWN::AI::Agent::Dispatch.call(
15
+ tool_call: {
16
+ function: {
17
+ name: 'pwn_eval',
18
+ arguments: JSON.generate(code: 'PWN::Plugins::BasicAuth.encode(not_a_key: 1)')
19
+ }
20
+ },
21
+ scope_path: File.join(Dir.tmpdir, 'absent.yaml')
22
+ )
23
+ row = JSON.parse(raw, symbolize_names: true).fetch(:result)
24
+ expect(row[:error].to_s).to match(/unknown keyword/i)
25
+ expect(row[:schema]).to be_a(Hash)
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'PWN::AI::Agent::Tools radare2' do
6
+ before(:all) do
7
+ PWN::AI::Agent::Registry.discover(force: true)
8
+ load '/opt/pwn/lib/pwn/ai/agent/tools/radare2.rb'
9
+ end
10
+
11
+ it 'registers r2_functions and r2_disasm' do
12
+ expect(PWN::AI::Agent::Registry.lookup(name: 'r2_functions')).not_to be_nil
13
+ expect(PWN::AI::Agent::Registry.lookup(name: 'r2_disasm')).not_to be_nil
14
+ end
15
+ end
@@ -105,6 +105,20 @@ describe PWN::AI::Agent::TurnFinalizer do
105
105
  end
106
106
 
107
107
  describe 'output path literals' do
108
+ it 'precommits immutable required_artifacts before cwd or request processing can drift' do
109
+ Dir.mktmpdir do |dir|
110
+ request = 'Read /tmp/input.md and write ./answer.md'
111
+ contract = described_class.commit_artifacts!(request: request, cwd: dir)
112
+ expect(contract[:required_artifacts]).to eq([File.join(dir, 'answer.md')])
113
+ expect(contract).to be_frozen
114
+ expect(contract[:required_artifacts]).to be_frozen
115
+ expect(described_class.required_artifacts(request: request)).to eq(contract[:required_artifacts])
116
+ expect(described_class.arbitrate(request: request, messages: [])[:unmet]).to include(criterion: 'artifact_missing', detail: File.join(dir, 'answer.md'))
117
+ end
118
+ ensure
119
+ Thread.current[:pwn_artifact_contract] = nil
120
+ end
121
+
108
122
  it 'does not interpret words inside earlier filenames as new instructions' do
109
123
  expect(described_class.output_paths(request: 'Write /tmp/read.md and /tmp/answer.md')).to eq(['/tmp/read.md', '/tmp/answer.md'])
110
124
  end
@@ -124,6 +138,68 @@ describe PWN::AI::Agent::TurnFinalizer do
124
138
  end
125
139
  end
126
140
 
141
+ it 'rejects host observations from an earlier turn even when stat and digest still match' do
142
+ Dir.mktmpdir do |dir|
143
+ path = File.join(dir, 'answer.md')
144
+ request = "Write #{path}"
145
+ described_class.commit_artifacts!(request: request)
146
+ before = described_class.artifact_snapshot(paths: [path])
147
+ File.write(path, 'verified first turn')
148
+ observations = described_class.observe_artifacts(paths: [path], before: before, effect: :write, success: true)
149
+ messages = [{ role: 'tool', artifact_observations: observations }]
150
+ expect(described_class.arbitrate(request: request, messages: messages)[:complete]).to eq(true)
151
+ described_class.commit_artifacts!(request: request)
152
+ result = described_class.arbitrate(request: request, messages: messages)
153
+ expect(result[:unmet]).to include(criterion: 'write_missing', detail: path)
154
+ expect(result[:unmet]).to include(criterion: 'readback_missing', detail: path)
155
+ end
156
+ ensure
157
+ Thread.current[:pwn_artifact_contract] = nil
158
+ end
159
+
160
+ it 'gates finalization on real Dispatch writes and current-turn stat plus SHA-256 for every committed path' do
161
+ PWN::AI::Agent::Registry.discover
162
+ loop_module = PWN::AI::Agent::Loop
163
+ allow(loop_module).to receive(:evidence_satisfied?).and_return(true)
164
+ allow(loop_module).to receive(:record_artifact_bounce)
165
+ Dir.mktmpdir do |dir|
166
+ paths = %w[first.md second.md].map { |name| File.join(dir, name) }
167
+ request = "Write #{paths.join(' and ')}"
168
+ contract = described_class.commit_artifacts!(request: request)
169
+ messages = [{ role: 'assistant', content: 'Both files were written and verified.' }]
170
+ finalize = -> { loop_module.send(:may_finalize?, request: request, messages: messages, text: 'Delivered both files.') }
171
+ expect(finalize.call).to eq(false)
172
+ paths.each_with_index do |path, index|
173
+ code = "File.write(#{path.inspect}, 'verified artifact #{index}')"
174
+ args = index.zero? ? { code: code } : { encoding: 'base64', data: Base64.strict_encode64(code) }
175
+ before = described_class.artifact_snapshot(paths: contract[:required_artifacts])
176
+ raw = PWN::AI::Agent::Dispatch.call(scope_policy: { enabled: false }, tool_call: { function: { name: 'pwn_eval', arguments: JSON.generate(args) } })
177
+ parsed = JSON.parse(raw)
178
+ expect(parsed.dig('result', 'error')).to be_nil
179
+ effect = PWN::AI::Agent::Dispatch.effect(name: 'pwn_eval', args: args)
180
+ expect(effect).to eq(:write)
181
+ observed = described_class.observe_artifacts(paths: contract[:required_artifacts], before: before, effect: effect, success: parsed['success'])
182
+ expect(observed.fetch(path)[:sha256]).to eq(Digest::SHA256.file(path).hexdigest)
183
+ expect(observed.fetch(path)[:stat][:size]).to eq(File.size(path))
184
+ messages << { role: 'tool', content: raw, artifact_observations: observed }
185
+ expect(finalize.call).to eq(index == 1)
186
+ end
187
+ File.write(paths.last, 'changed after readback')
188
+ expect(finalize.call).to eq(false)
189
+ end
190
+ ensure
191
+ Thread.current[:pwn_artifact_contract] = nil
192
+ Thread.current[:pwn_dispatch_budget] = nil
193
+ end
194
+
195
+ it 'precommits before cheap returns and uses the committed list for dispatch observation' do
196
+ source = File.read(PWN::AI::Agent::Loop.method(:run).source_location.first)
197
+ run = source[source.index('public_class_method def self.run(opts = {})')..]
198
+ expect(run.index('commit_artifacts!')).to be < run.index('cheap =')
199
+ expect(run).to include('allow_text_only = required_artifacts.empty?', 'cheap = allow_text_only', 'declared_paths = required_artifacts')
200
+ expect(run).to include('Thread.current[:pwn_artifact_contract] = prior_artifact_contract')
201
+ end
202
+
127
203
  it 'requires a current-turn write even when a file is newly present and readable' do
128
204
  Dir.mktmpdir do |dir|
129
205
  path = File.join(dir, 'answer.md')
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'yaml'
4
5
  require 'open3'
5
6
  require 'tmpdir'
6
7
 
@@ -91,11 +92,21 @@ RSpec.describe 'PWN::AI::CLI' do
91
92
  expect { PWN::AI::CLI.help }.to output(/\.parse.*\.run.*\.authors/m).to_stdout
92
93
  end
93
94
 
95
+ it 'emits a YAML DAG for --plan-only without running the agent loop' do
96
+ expect(PWN::AI::Agent::Loop).not_to receive(:run)
97
+ expect(PWN::AI::Agent::Dispatch).not_to receive(:call)
98
+ out = StringIO.new
99
+ expect(PWN::AI::CLI.run(argv: ['--plan-only', '--ai', 'echo one then echo two'], output: out)).to eq(0)
100
+ dag = YAML.safe_load(out.string)
101
+ expect(dag['steps'].length).to be >= 2
102
+ expect(dag['steps'].first).to include('tool', 'args', 'side_effect', 'dependencies')
103
+ end
104
+
94
105
  it 'prints executable help without reading or creating a vault' do
95
106
  Dir.mktmpdir do |home|
96
107
  output, status = Open3.capture2e({ 'HOME' => home }, 'ruby', '-Ilib', 'bin/pwn-ai', '--help')
97
108
  expect(status.success?).to be(true), output
98
- expect(output).to include('--analyze', '--replay', '--rerun')
109
+ expect(output).to include('--analyze', '--replay', '--rerun', '--plan-only', '--resume')
99
110
  expect(Dir.children(home)).to eq([])
100
111
  end
101
112
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ describe PWN::AI::Router do
7
+ it 'should display information for authors' do
8
+ expect(described_class).to respond_to :authors
9
+ end
10
+
11
+ it 'should display information for existing help method' do
12
+ expect(described_class).to respond_to :help
13
+ end
14
+
15
+ it 'maps task classes from ai_router with fallback chains' do
16
+ stub_const('PWN::Env', {
17
+ ai_router: {
18
+ summarize: { engine: 'ollama', model: 'tinyllama', fallbacks: [{ engine: 'openwebui' }] },
19
+ plan: { engine: 'ollama', fallbacks: [] },
20
+ exploit_dev: { engine: 'ollama', model: 'qwen2.5-coder', fallbacks: [{ engine: 'openai', model: 'gpt-5' }] },
21
+ triage: { engine: 'ollama', fallbacks: [{ engine: 'openwebui' }] }
22
+ },
23
+ ai: { active: 'grok', ollama: { model: 'tinyllama' } }
24
+ })
25
+ sum = described_class.resolve(task: :summarize)
26
+ expect(sum[:endpoints].first[:engine].to_s).to eq('ollama')
27
+ expect(sum[:allow_frontier]).to eq(false)
28
+ exp = described_class.resolve(task: :exploit_dev)
29
+ expect(exp[:endpoints].map { |row| row[:engine].to_s }).to include('ollama', 'openai')
30
+ expect(exp[:endpoints].first[:model].to_s).to include('coder')
31
+ end
32
+
33
+ it 'never spends frontier-model tokens on artifact summarization' do
34
+ Dir.mktmpdir('pwn-router-') do |dir|
35
+ allow(Dir).to receive(:home).and_return(dir)
36
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
37
+ stub_const('PWN::Env', {
38
+ ai_router: {
39
+ summarize: { engine: 'grok', model: 'grok-4', fallbacks: [{ engine: 'openai' }, { engine: 'ollama', model: 'tinyllama' }] }
40
+ },
41
+ ai: { active: 'grok', grok: { model: 'grok-4' }, openai: { model: 'gpt-5' }, ollama: { model: 'tinyllama' } }
42
+ })
43
+ described_class.reset_usage
44
+ %i[Grok OpenAI Anthropic Gemini].each do |name|
45
+ allow(PWN::AI.const_get(name)).to receive(:chat).and_raise('frontier must not be called')
46
+ end
47
+ allow(PWN::AI::Ollama).to receive(:chat).and_return('local artifact summary')
48
+ path = File.join(dir, 'big.txt')
49
+ File.write(path, 'A' * (PWN::AI::Context::WINDOW + 50))
50
+ row = PWN::AI::Context.attach_file(path: path, session_id: 'sum')
51
+ expect(row[:summarized]).to eq(true)
52
+ expect(described_class.usage.dig(:summarize, :frontier_tokens)).to eq(0)
53
+ expect(PWN::AI::Grok).not_to have_received(:chat)
54
+ expect(PWN::AI::OpenAI).not_to have_received(:chat)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::FFI::Capstone do
6
+ it 'should display information for authors' do
7
+ expect(described_class).to respond_to :authors
8
+ end
9
+
10
+ it 'should display information for existing help method' do
11
+ expect(described_class).to respond_to :help
12
+ end
13
+
14
+ it 'should respond to available?' do
15
+ expect(described_class).to respond_to :available?
16
+ end
17
+
18
+ it 'rejects disassemble when libcapstone is unavailable' do
19
+ allow(described_class).to receive(:available?).and_return(false)
20
+ expect { described_class.disassemble(bytes: "\x90") }.to raise_error(RuntimeError, /libcapstone not available/)
21
+ end
22
+
23
+ before(:each, :capstone_integration) do
24
+ expect(described_class.available?).to be(true),
25
+ "PWN_TEST_CAPSTONE=1 requires libcapstone. Load error: #{described_class.load_error}"
26
+ end
27
+
28
+ it 'disassembles nop', :capstone_integration do
29
+ row = described_class.disassemble(bytes: "\x90\xc3", arch: 'x86_64')
30
+ expect(row[:engine]).to eq('capstone')
31
+ expect(row[:insns].map { |i| i[:mnemonic] }).to include('nop')
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::FFI::Keystone do
6
+ it 'should display information for authors' do
7
+ expect(described_class).to respond_to :authors
8
+ end
9
+
10
+ it 'should display information for existing help method' do
11
+ expect(described_class).to respond_to :help
12
+ end
13
+
14
+ it 'should respond to available?' do
15
+ expect(described_class).to respond_to :available?
16
+ end
17
+
18
+ it 'rejects assemble when libkeystone is unavailable' do
19
+ allow(described_class).to receive(:available?).and_return(false)
20
+ expect { described_class.assemble(asm: 'nop') }.to raise_error(RuntimeError, /libkeystone not available/)
21
+ end
22
+
23
+ before(:each, :keystone_integration) do
24
+ expect(described_class.available?).to be(true),
25
+ "PWN_TEST_KEYSTONE=1 requires libkeystone. Load error: #{described_class.load_error}"
26
+ end
27
+
28
+ it 'assembles nop; ret', :keystone_integration do
29
+ row = described_class.assemble(asm: "nop\nret", arch: 'x86_64')
30
+ expect(row[:engine]).to eq('keystone')
31
+ expect(row[:bytes].bytesize).to be >= 2
32
+ end
33
+ end
@@ -80,11 +80,11 @@ describe PWN::Migrate do
80
80
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 2))
81
81
  expect(described_class.needed?).to be(true)
82
82
  result = described_class.run(fix: false, backup: false, io: io)
83
- expect(result[:applied_migrations]).to eq([3])
83
+ expect(result[:applied_migrations]).to eq([3, 4])
84
84
  expected = Marshal.load(Marshal.dump(original))
85
85
  expected['custom']['model'] = nil
86
86
  expect(YAML.safe_load_file(path)).to eq(expected)
87
- expect(described_class.installed_schema).to eq(3)
87
+ expect(described_class.installed_schema).to eq(described_class::SCHEMA_VERSION)
88
88
  bytes = File.binread(path)
89
89
  described_class::MIGRATIONS.fetch(3).call(@tmp, io)
90
90
  expect(File.binread(path)).to eq(bytes)
@@ -174,10 +174,38 @@ describe PWN::Migrate do
174
174
  stale = { ai: { active: 'grok', grok: { key: 'x' } } }
175
175
  miss = PWN::Migrate.send(:missing_paths, tmpl: PWN::Config.env_template, user: stale)
176
176
  expect(miss).to include('ai.agent')
177
+ expect(miss).to include('ai_sandbox')
178
+ expect(miss).to include('ai_router')
177
179
  expect(miss).to include('memory')
178
180
  expect(miss).not_to include('ai.active')
179
181
  end
180
182
 
183
+ it 'upgrades a schema-3 vault with new pwn.yaml keys without changing user values' do
184
+ yaml = File.join(@tmp, 'pwn.yaml')
185
+ dec = File.join(@tmp, 'pwn.yaml.decryptor')
186
+ stale = { ai: { active: 'grok', grok: { key: 'sk-KEEP' } } }
187
+ File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
188
+ PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
189
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 3))
190
+ expect(described_class.needed?).to be(true)
191
+ result = described_class.run(fix: true, backup: false, io: io)
192
+ expect(result[:applied_migrations]).to eq([4])
193
+ creds = YAML.safe_load_file(dec, symbolize_names: true)
194
+ user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
195
+ expect(user[:ai][:active]).to eq('grok')
196
+ expect(user[:ai][:grok][:key]).to eq('sk-KEEP')
197
+ expect(user[:ai_sandbox]).to eq('off')
198
+ expect(user[:ai_router]).to be_a(Hash)
199
+ expect(user[:ai_router][:summarize] || user[:ai_router]['summarize']).to be_a(Hash)
200
+ routes = user.dig(:ai, :agent, :model_routes) || user.dig('ai', 'agent', 'model_routes')
201
+ expect(routes).to include(:exploit_dev).or include('exploit_dev')
202
+ second = described_class.run(fix: true, backup: false, io: io)
203
+ expect(second[:applied_migrations]).to eq([])
204
+ again = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
205
+ expect(again[:ai][:grok][:key]).to eq('sk-KEEP')
206
+ expect(described_class.needed?).to be(false)
207
+ end
208
+
181
209
  it 'dry_run writes nothing' do
182
210
  r = PWN::Migrate.run(fix: true, backup: true, dry_run: true, io: io)
183
211
  expect(r[:dry_run]).to be true