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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +163 -6
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +6 -2
- data/documentation/Recon-Findings-API.md +33 -1
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
- data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
- data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
- data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
- data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
- data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
- data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/SKILL.md +2 -0
- data/lib/pwn/ai/agent/confirmation.rb +163 -0
- data/lib/pwn/ai/agent/dispatch.rb +43 -5
- data/lib/pwn/ai/agent/loop.rb +45 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
- data/lib/pwn/ai/agent/registry.rb +2 -1
- data/lib/pwn/ai/agent/result.rb +76 -6
- data/lib/pwn/ai/agent/task_dag.rb +220 -0
- data/lib/pwn/ai/agent/tool_guard.rb +166 -24
- data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
- data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
- data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
- data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
- data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
- data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
- data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
- data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
- data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
- data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
- data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
- data/lib/pwn/ai/agent/tools/shell.rb +22 -10
- data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
- data/lib/pwn/ai/agent.rb +3 -1
- data/lib/pwn/ai/cli.rb +26 -2
- data/lib/pwn/ai/context.rb +4 -2
- data/lib/pwn/ai/router.rb +196 -0
- data/lib/pwn/ai/tools/README.md +5 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +10 -0
- data/lib/pwn/engagement.rb +45 -1
- data/lib/pwn/ffi/capstone.rb +120 -0
- data/lib/pwn/ffi/keystone.rb +107 -0
- data/lib/pwn/ffi.rb +2 -0
- data/lib/pwn/migrate.rb +5 -1
- data/lib/pwn/plugins/aflplusplus.rb +238 -13
- data/lib/pwn/plugins/ai_sandbox.rb +240 -0
- data/lib/pwn/plugins/artifact_registry.rb +188 -22
- data/lib/pwn/plugins/assembly.rb +73 -2
- data/lib/pwn/plugins/basic_auth.rb +17 -4
- data/lib/pwn/plugins/binary_parser.rb +439 -38
- data/lib/pwn/plugins/exploit_dev.rb +83 -14
- data/lib/pwn/plugins/findings.rb +208 -42
- data/lib/pwn/plugins/gdbmi.rb +265 -38
- data/lib/pwn/plugins/httpx.rb +93 -0
- data/lib/pwn/plugins/jobs.rb +260 -102
- data/lib/pwn/plugins/method_catalog.rb +328 -0
- data/lib/pwn/plugins/nmap_it.rb +157 -26
- data/lib/pwn/plugins/nuclei.rb +122 -29
- data/lib/pwn/plugins/preflight_checker.rb +3 -2
- data/lib/pwn/plugins/process_tube.rb +51 -2
- data/lib/pwn/plugins/radare2.rb +120 -26
- data/lib/pwn/plugins/recon.rb +33 -0
- data/lib/pwn/plugins/repl/mesh.rb +203 -13
- data/lib/pwn/plugins/rop.rb +121 -31
- data/lib/pwn/plugins/transparent_browser.rb +212 -14
- data/lib/pwn/plugins/vault.rb +182 -24
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +8 -2
- data/lib/pwn/reports/html.rb +28 -9
- data/lib/pwn/reports/json.rb +2 -1
- data/lib/pwn/reports/markdown.rb +49 -12
- data/lib/pwn/reports/sarif.rb +17 -3
- data/lib/pwn/reports.rb +184 -34
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reports_spec.rb +93 -0
- data/spec/integration/result_condition_spec.rb +11 -8
- data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
- data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
- data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
- data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
- data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
- data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
- data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
- data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
- data/spec/lib/pwn/ai/cli_spec.rb +12 -1
- data/spec/lib/pwn/ai/router_spec.rb +57 -0
- data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
- data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
- data/spec/lib/pwn/migrate_spec.rb +30 -2
- data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
- data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
- data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
- data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
- data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
- data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
- data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
- data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
- data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
- data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
- data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
- data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
- data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
- data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
- data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
- data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
- data/spec/lib/pwn/reports_spec.rb +196 -0
- data/spec/spec_helper.rb +3 -0
- data/third_party/pwn_rdoc.jsonl +230 -4
- 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', '
|
|
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')
|
data/spec/lib/pwn/ai/cli_spec.rb
CHANGED
|
@@ -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(
|
|
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
|