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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require 'fileutils'
|
|
8
|
+
|
|
9
|
+
describe 'PWN::AI::Agent::Dispatch confirmation tiers' do
|
|
10
|
+
it 'does not execute an exploit-tier tool until the engagement ACK is cached' do
|
|
11
|
+
Dir.mktmpdir('pwn-ack-dispatch-') do |dir|
|
|
12
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
13
|
+
ran = false
|
|
14
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
15
|
+
PWN::AI::Agent::Registry.register(
|
|
16
|
+
name: 'spec_exploit_tier',
|
|
17
|
+
toolset: 'pwn',
|
|
18
|
+
schema: {
|
|
19
|
+
name: 'spec_exploit_tier',
|
|
20
|
+
description: 'fixture exploit-tier tool',
|
|
21
|
+
parameters: { type: 'object', properties: { action: { type: 'string' } }, required: %w[action] }
|
|
22
|
+
},
|
|
23
|
+
handler: lambda { |_args|
|
|
24
|
+
ran = true
|
|
25
|
+
{ ran: true }
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
scope = File.join(dir, '.pwn', 'scope.yaml')
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(scope))
|
|
30
|
+
File.write(scope, YAML.dump('enabled' => false, 'confirmation' => { 'exploit' => 'prompt', 'destructive' => 'prompt', 'read_only' => 'auto', 'active_scan' => 'auto' }))
|
|
31
|
+
raw = PWN::AI::Agent::Dispatch.call(
|
|
32
|
+
tool_call: { function: { name: 'spec_exploit_tier', arguments: JSON.generate(action: 'ret2libc') } },
|
|
33
|
+
engagement_id: 'lab',
|
|
34
|
+
scope_path: scope
|
|
35
|
+
)
|
|
36
|
+
row = JSON.parse(raw, symbolize_names: true)
|
|
37
|
+
expect(ran).to eq(false)
|
|
38
|
+
expect(row[:needs_ack]).to eq(true)
|
|
39
|
+
expect(row[:diff].to_s).to include('spec_exploit_tier')
|
|
40
|
+
acked = PWN::AI::Agent::Dispatch.call(
|
|
41
|
+
tool_call: { function: { name: 'spec_exploit_tier', arguments: JSON.generate(action: 'ret2libc') } },
|
|
42
|
+
engagement_id: 'lab',
|
|
43
|
+
operator_ack: true,
|
|
44
|
+
scope_path: scope
|
|
45
|
+
)
|
|
46
|
+
expect(JSON.parse(acked, symbolize_names: true)[:success]).to eq(true)
|
|
47
|
+
expect(ran).to eq(true)
|
|
48
|
+
ensure
|
|
49
|
+
PWN::AI::Agent::Registry.instance_variable_get(:@entries).delete('spec_exploit_tier')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -29,6 +29,57 @@ RSpec.describe PWN::AI::Agent::Dispatch do
|
|
|
29
29
|
expect(out.dig(:result, :stdout)).to include(' ... ')
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
it 'explains an elided command after markdown data and accepts one corrected call' do
|
|
33
|
+
PWN::AI::Agent::Registry.discover
|
|
34
|
+
command = "cat <<-'MD'\n# Résumé ... inline prose\n...\n\tMD\n...\n"
|
|
35
|
+
denied = dispatch('shell', { command: command, timeout: 2 }).fetch(:result)
|
|
36
|
+
expect(denied).to include(error: 'invalid_payload', code: 'SYNTAX_DENY', rule_id: 'payload.placeholder', match: '...')
|
|
37
|
+
expect(denied[:offset]).to eq(command.b.rindex("\n...".b) + 1)
|
|
38
|
+
expect(command.byteslice(denied[:offset], denied[:match].bytesize)).to eq('...')
|
|
39
|
+
expect(denied[:remedy]).to include('placeholder_ok', 'base64', 'data')
|
|
40
|
+
expect(denied[:remedy]).to include('does not bypass')
|
|
41
|
+
corrected_command = command.byteslice(0, denied[:offset])
|
|
42
|
+
corrected = dispatch('shell', { command: corrected_command, timeout: 2 })
|
|
43
|
+
expect(corrected.dig(:result, :exit)).to eq(0)
|
|
44
|
+
expect(corrected.dig(:result, :stdout)).to include("# Résumé ... inline prose\n...\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'preserves decoded byte offsets through base64 and does not present encoding as a validation bypass' do
|
|
48
|
+
PWN::AI::Agent::Registry.discover
|
|
49
|
+
command = "cat <<-'MD'\n# Résumé ...\n…\n\tMD\n…\n"
|
|
50
|
+
args = { encoding: 'base64', data: Base64.strict_encode64(command), timeout: 2 }
|
|
51
|
+
denied = dispatch('shell', args).fetch(:result)
|
|
52
|
+
expect(denied[:match]).to eq('…')
|
|
53
|
+
expect(denied[:offset]).to eq(command.b.rindex('…'.b))
|
|
54
|
+
expect(denied[:byte_range]).to eq([denied[:offset], denied[:offset] + '…'.bytesize])
|
|
55
|
+
corrected = dispatch('shell', args.merge(data: Base64.strict_encode64(command.byteslice(0, denied[:offset]))))
|
|
56
|
+
expect(corrected.dig(:result, :exit)).to eq(0)
|
|
57
|
+
expect(corrected.dig(:result, :stdout)).to include('…')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'explains schema invalid_payload errors with the field and correction' do
|
|
61
|
+
PWN::AI::Agent::Registry.discover
|
|
62
|
+
denied = dispatch('shell', { command: 'printf ok', timeout: 20_000 })
|
|
63
|
+
expect(denied).to include(error: 'invalid_payload', rule_id: 'schema.maximum', offset: nil)
|
|
64
|
+
expect(denied[:match]).to eq('20000')
|
|
65
|
+
expect(denied[:remedy]).to include('/timeout', '10800')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'writes Ruby literal ellipses as data and denies a code stub before side effects' do
|
|
69
|
+
PWN::AI::Agent::Registry.discover
|
|
70
|
+
path = File.join(@dir, 'document.md')
|
|
71
|
+
code = "doc_with_ellipsis = <<~DOC\n # Résumé\n ...\n def foo(<3dots>)\nDOC\nFile.write(#{path.inspect}, doc_with_ellipsis)"
|
|
72
|
+
result = dispatch('pwn_eval', { code: code, timeout: 2 })
|
|
73
|
+
expect(result.dig(:result, :error)).to be_nil
|
|
74
|
+
expect(File.read(path)).to eq("# Résumé\n...\ndef foo(<3dots>)\n")
|
|
75
|
+
File.delete(path)
|
|
76
|
+
stub = "File.write(#{path.inspect}, 'should not execute'); def foo(<3dots>); end"
|
|
77
|
+
denied = dispatch('pwn_eval', { code: stub, timeout: 2 }).fetch(:result)
|
|
78
|
+
expect(denied).to include(error: 'invalid_payload', rule_id: 'payload.placeholder', match: '<3dots>')
|
|
79
|
+
expect(denied[:offset]).to eq(stub.b.index('<3dots>'))
|
|
80
|
+
expect(File.exist?(path)).to eq(false)
|
|
81
|
+
end
|
|
82
|
+
|
|
32
83
|
it 'loads a YAML-whitelisted plugin method as a callable tool' do
|
|
33
84
|
PWN::AI::Agent::Registry.discover
|
|
34
85
|
entry = PWN::AI::Agent::Registry.lookup(name: 'host_os_type')
|
|
@@ -683,11 +683,97 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
683
683
|
chunk = src[/if Thread\.current\[:pwn_extinguished\].*?tools_called \+= 1/m]
|
|
684
684
|
expect(chunk).to include('same_n = note_same_payload!')
|
|
685
685
|
expect(chunk.index('note_same_payload!')).to be < chunk.index('Dispatch.call')
|
|
686
|
+
expect(chunk.index('Dispatch.call')).to be < chunk.index('note_payload_result!')
|
|
686
687
|
expect(chunk).to match(/same_n >= 3/)
|
|
687
688
|
raw = described_class.send(:checkpoint_result, name: 'shell', args: '{"command":"echo same"}')
|
|
688
689
|
expect(JSON.parse(raw)).to include('success' => false, 'checkpoint' => true)
|
|
689
690
|
end
|
|
690
691
|
|
|
692
|
+
it 'allows verbatim retries after nonzero exits and checkpoints repeated successes' do
|
|
693
|
+
Thread.current[:pwn_same_payload] = Hash.new(0)
|
|
694
|
+
args = { command: 'test -f ready', timeout: 2 }
|
|
695
|
+
4.times do
|
|
696
|
+
expect(described_class.send(:note_same_payload!, name: 'shell', args: args)).to eq(1)
|
|
697
|
+
raw = JSON.generate(success: true, result: { exit: 1, stdout: '', stderr: '' })
|
|
698
|
+
described_class.send(:note_payload_result!, name: 'shell', args: args, raw: raw)
|
|
699
|
+
expect(described_class.send(:guard_repeated_failure, name: 'shell', args: args, count: 650, raw: raw, result: raw)).to eq(raw)
|
|
700
|
+
end
|
|
701
|
+
expect(described_class.send(:note_same_payload!, name: 'shell', args: args)).to eq(1)
|
|
702
|
+
described_class.send(:note_payload_result!, name: 'shell', args: args, raw: JSON.generate(success: true, result: { exit: 0 }))
|
|
703
|
+
expect(described_class.send(:note_same_payload!, name: 'shell', args: args)).to eq(2)
|
|
704
|
+
expect(described_class.send(:note_same_payload!, name: 'shell', args: args)).to eq(3)
|
|
705
|
+
ensure
|
|
706
|
+
Thread.current[:pwn_same_payload] = nil
|
|
707
|
+
Thread.current[:pwn_extinguished] = nil
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
it 'retries a real failing shell command verbatim until it succeeds without a checkpoint' do
|
|
711
|
+
PWN::AI::Agent::Registry.discover
|
|
712
|
+
Thread.current[:pwn_same_payload] = Hash.new(0)
|
|
713
|
+
Thread.current[:pwn_dispatch_budget] = nil
|
|
714
|
+
Dir.mktmpdir('retry-checkpoint') do |dir|
|
|
715
|
+
program = 'p=ARGV.fetch(0); n=File.exist?(p) ? File.read(p).to_i+1 : 1; File.write(p,n); puts n; exit(n<4 ? 7 : 0)'
|
|
716
|
+
args = { command: "ruby -e '#{program}' #{File.join(dir, 'attempts').inspect}", timeout: 10 }
|
|
717
|
+
exits = 4.times.map do
|
|
718
|
+
expect(described_class.send(:note_same_payload!, name: 'shell', args: args)).to eq(1)
|
|
719
|
+
raw = PWN::AI::Agent::Dispatch.call(scope_policy: { enabled: false }, tool_call: { function: { name: 'shell', arguments: JSON.generate(args) } })
|
|
720
|
+
described_class.send(:note_payload_result!, name: 'shell', args: args, raw: raw)
|
|
721
|
+
parsed = JSON.parse(raw)
|
|
722
|
+
expect(parsed['error']).to be_nil
|
|
723
|
+
parsed.dig('result', 'exit')
|
|
724
|
+
end
|
|
725
|
+
expect(exits).to eq([7, 7, 7, 0])
|
|
726
|
+
expect(File.read(File.join(dir, 'attempts'))).to eq('4')
|
|
727
|
+
end
|
|
728
|
+
ensure
|
|
729
|
+
Thread.current[:pwn_same_payload] = nil
|
|
730
|
+
Thread.current[:pwn_dispatch_budget] = nil
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
it 'permits repeated timeout retries while Dispatch increases the same payload deadline' do
|
|
734
|
+
PWN::AI::Agent::Registry.discover
|
|
735
|
+
Thread.current[:pwn_same_payload] = Hash.new(0)
|
|
736
|
+
Thread.current[:pwn_dispatch_budget] = nil
|
|
737
|
+
entry = PWN::AI::Agent::Registry.lookup(name: 'shell')
|
|
738
|
+
deadlines = []
|
|
739
|
+
allow(entry.handler).to receive(:call) do |args|
|
|
740
|
+
deadlines << args[:timeout]
|
|
741
|
+
deadlines.length < 4 ? { error: 'timeout after test deadline' } : { exit: 0, stdout: 'finished' }
|
|
742
|
+
end
|
|
743
|
+
args = { command: 'unchanged command', timeout: 1 }
|
|
744
|
+
4.times do
|
|
745
|
+
expect(described_class.send(:note_same_payload!, name: 'shell', args: args)).to eq(1)
|
|
746
|
+
raw = PWN::AI::Agent::Dispatch.call(scope_policy: { enabled: false }, tool_call: { function: { name: 'shell', arguments: JSON.generate(args) } })
|
|
747
|
+
described_class.send(:note_payload_result!, name: 'shell', args: args, raw: raw)
|
|
748
|
+
expect(JSON.parse(raw)['error']).to be_nil
|
|
749
|
+
result = described_class.send(:guard_repeated_failure, name: 'shell', args: args, count: 650, raw: raw, result: raw) if deadlines.length < 4
|
|
750
|
+
expect(result).not_to include('EXTINGUISHED') if result
|
|
751
|
+
end
|
|
752
|
+
expect(deadlines).to eq([1, 181, 361, 541])
|
|
753
|
+
expect(Thread.current[:pwn_dispatch_budget].dig(:chains, 'shell', :active)).to be_nil
|
|
754
|
+
ensure
|
|
755
|
+
Thread.current[:pwn_same_payload] = nil
|
|
756
|
+
Thread.current[:pwn_dispatch_budget] = nil
|
|
757
|
+
Thread.current[:pwn_extinguished] = nil
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
it 'does not mistake policy denials, checkpoints or stdout text for retryable execution failures' do
|
|
761
|
+
[
|
|
762
|
+
{ checkpoint: true, result: { exit: 1 } },
|
|
763
|
+
{ error: 'retry_required' },
|
|
764
|
+
{ error: 'budget_exhausted', result: { error: 'timeout' } },
|
|
765
|
+
{ code: 'SCOPE_DENY', result: { exit: 1 } },
|
|
766
|
+
{ success: true, result: { exit: 0, stdout: 'timeout exit_code=1' } },
|
|
767
|
+
{ success: false, error: 'invalid_payload' }
|
|
768
|
+
].each do |raw|
|
|
769
|
+
expect(described_class.send(:retryable_execution_failure?, raw: JSON.generate(raw))).to eq(false)
|
|
770
|
+
end
|
|
771
|
+
[{ result: { exit_code: 2 } }, { result: { exit_status: '3' } }, { error: 'Timeout::Error: expired' }, { result: { timed_out: true } }].each do |raw|
|
|
772
|
+
expect(described_class.send(:retryable_execution_failure?, raw: raw)).to eq(true)
|
|
773
|
+
end
|
|
774
|
+
expect(described_class.send(:retryable_execution_failure?, raw: 'timeout in plain text')).to eq(false)
|
|
775
|
+
end
|
|
776
|
+
|
|
691
777
|
it 'records a timeout increment mistake instead of treating success:true as ok' do
|
|
692
778
|
tmp = Dir.mktmpdir
|
|
693
779
|
stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(tmp, 'mistakes.json'))
|
|
@@ -17,7 +17,7 @@ describe PWN::AI::Agent::Registry do
|
|
|
17
17
|
it 'CORE_TOOLS is recall-then-act including skills_recall before pwn_eval' do
|
|
18
18
|
expect(described_class.const_defined?(:ACT_PREFERENCE)).to eq false
|
|
19
19
|
expect(described_class::CORE_TOOLS).to eq(
|
|
20
|
-
%w[memory_recall session_recall skills_recall pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember skills_update]
|
|
20
|
+
%w[memory_recall session_recall skills_recall pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember skills_update artifact_read artifact_grep job_run job_status job_tail job_result job_kill]
|
|
21
21
|
)
|
|
22
22
|
expect(described_class::DEFAULT_PREFERENCE).to eq(described_class::CORE_TOOLS)
|
|
23
23
|
expect(described_class::DEFAULT_PREFERENCE).not_to include('sessions_view')
|
|
@@ -1,8 +1,91 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
4
5
|
|
|
5
6
|
describe PWN::AI::Agent::Result do
|
|
7
|
+
around do |example|
|
|
8
|
+
Dir.mktmpdir('pwn-result-') do |dir|
|
|
9
|
+
@artifact_root = dir
|
|
10
|
+
example.run
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
before { stub_const('PWN::Plugins::ArtifactRegistry::ROOT', @artifact_root) }
|
|
15
|
+
|
|
16
|
+
it 'spills real Dispatch output before redaction while retaining failure status' do
|
|
17
|
+
PWN::AI::Agent::Registry.discover
|
|
18
|
+
file = File.join(@artifact_root, 'strings.txt')
|
|
19
|
+
size = 50 * 1024 * 1024
|
|
20
|
+
row = "strings payload\n"
|
|
21
|
+
content = (row * ((size / row.bytesize) + 1)).byteslice(0, size)
|
|
22
|
+
File.binwrite(file, content)
|
|
23
|
+
response = JSON.parse(PWN::AI::Agent::Dispatch.call(
|
|
24
|
+
tool_call: { function: { name: 'shell', arguments: { command: "cat #{file}; exit 7", timeout: 5 } } },
|
|
25
|
+
scope_path: File.join(@artifact_root, 'absent.yml')
|
|
26
|
+
))
|
|
27
|
+
result = response.fetch('result')
|
|
28
|
+
expect(result['exit']).to eq(7)
|
|
29
|
+
expect(result['artifact']).to be_a(Hash)
|
|
30
|
+
saved = JSON.parse(File.binread(result['artifact']['path']))
|
|
31
|
+
expect(saved['stdout']).to eq(content)
|
|
32
|
+
expect(saved['exit']).to eq(7)
|
|
33
|
+
expect(PWN::AI::Agent::Loop.send(:retryable_execution_failure?, raw: JSON.generate(response))).to eq(true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'pages and searches through actual tool schemas within the smallest inline budget' do
|
|
37
|
+
PWN::AI::Agent::Registry.discover
|
|
38
|
+
allow(PWN::Env).to receive(:dig).and_call_original
|
|
39
|
+
allow(PWN::Env).to receive(:dig).with(:ai, :agent, :artifact_max_tokens).and_return(1024)
|
|
40
|
+
raw = ("\x00\xff".b * 4096) + "needle-at-end\n".b
|
|
41
|
+
summary = JSON.parse(described_class.condition(content: raw, session_id: 'roundtrip'))
|
|
42
|
+
handle = summary.fetch('artifact').fetch('handle')
|
|
43
|
+
reconstructed = +''.b
|
|
44
|
+
offset = 0
|
|
45
|
+
messages = [{ role: 'system', content: 'Read the saved bytes.' }, { role: 'user', content: 'Inspect the dump.' }]
|
|
46
|
+
loop do
|
|
47
|
+
tc = { id: "page-#{offset}", function: { name: 'artifact_read', arguments: { handle: handle, offset: offset, length: 50_000_000 } } }
|
|
48
|
+
response = PWN::AI::Agent::Dispatch.call(tool_call: tc, scope_path: File.join(@artifact_root, 'absent.yml'))
|
|
49
|
+
safe = described_class.condition(content: response)
|
|
50
|
+
expect(safe.bytesize).to be <= described_class.token_limit
|
|
51
|
+
page = JSON.parse(safe).fetch('result')
|
|
52
|
+
expect(page).not_to have_key('artifact')
|
|
53
|
+
reconstructed << Base64.strict_decode64(page.fetch('body'))
|
|
54
|
+
messages << { role: 'assistant', content: '', tool_calls: [tc] }
|
|
55
|
+
messages << { role: 'tool', tool_call_id: tc[:id], content: safe }
|
|
56
|
+
PWN::AI::Agent::Loop.send(:compact_history!, messages: messages)
|
|
57
|
+
expect(messages.length).to be <= 14
|
|
58
|
+
break if page.fetch('eof')
|
|
59
|
+
|
|
60
|
+
expect(page.fetch('next_offset')).to be > offset
|
|
61
|
+
offset = page.fetch('next_offset')
|
|
62
|
+
end
|
|
63
|
+
expect(reconstructed).to eq(raw)
|
|
64
|
+
response = PWN::AI::Agent::Dispatch.call(
|
|
65
|
+
tool_call: { function: { name: 'artifact_grep', arguments: { handle: handle, regex: 'needle-at-end' } } },
|
|
66
|
+
scope_path: File.join(@artifact_root, 'absent.yml')
|
|
67
|
+
)
|
|
68
|
+
safe = described_class.condition(content: response)
|
|
69
|
+
expect(safe.bytesize).to be <= described_class.token_limit
|
|
70
|
+
expect(JSON.parse(safe).dig('result', 'matches')).not_to be_empty
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'preserves oversized results byte for byte instead of truncating' do
|
|
74
|
+
raw = ("strings row\n" * 10_000).b + "\x00\xff".b
|
|
75
|
+
summary = JSON.parse(described_class.condition(content: raw, session_id: 'paging-test'))
|
|
76
|
+
expect(summary['artifact']['handle']).to match(%r{\Apaging-test/[0-9a-f]{8}\.bin\z})
|
|
77
|
+
expect(File.binread(summary['artifact']['path'])).to eq(raw)
|
|
78
|
+
expect(summary['artifact']['sha256']).to eq(Digest::SHA256.hexdigest(raw))
|
|
79
|
+
expect(summary['summary']).to include('artifact_read', 'artifact_grep')
|
|
80
|
+
expect(JSON.generate(summary).bytesize).to be < described_class.token_limit
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'spills above the configured conservative token bound' do
|
|
84
|
+
allow(PWN::Env).to receive(:dig).and_call_original
|
|
85
|
+
allow(PWN::Env).to receive(:dig).with(:ai, :agent, :artifact_max_tokens).and_return(2048)
|
|
86
|
+
expect(described_class.condition(content: 'x' * 2048)).to eq('x' * 2048)
|
|
87
|
+
expect(JSON.parse(described_class.condition(content: 'x' * 2049))['artifact']['bytes']).to eq(2049)
|
|
88
|
+
end
|
|
6
89
|
it 'should display information for authors' do
|
|
7
90
|
authors_response = PWN::AI::Agent::Result
|
|
8
91
|
expect(authors_response).to respond_to :authors
|
|
@@ -21,6 +104,6 @@ describe PWN::AI::Agent::Result do
|
|
|
21
104
|
big = 'x' * 10_000
|
|
22
105
|
out = described_class.condition(content: big)
|
|
23
106
|
expect(out.length).to be < 10_000
|
|
24
|
-
expect(out).to include('
|
|
107
|
+
expect(out).to include('artifact_read')
|
|
25
108
|
end
|
|
26
109
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require 'fileutils'
|
|
8
|
+
require 'timeout'
|
|
9
|
+
|
|
10
|
+
describe PWN::AI::Agent::TaskDAG do
|
|
11
|
+
it 'should display information for authors' do
|
|
12
|
+
expect(described_class).to respond_to :authors
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'should display information for existing help method' do
|
|
16
|
+
expect(described_class).to respond_to :help
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'emits a YAML DAG with steps, tool, args, side-effect tier, and dependencies' do
|
|
20
|
+
expect(PWN::AI::Agent::Dispatch).not_to receive(:call)
|
|
21
|
+
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
22
|
+
dag = described_class.plan(request: 'echo one then echo two')
|
|
23
|
+
yaml = YAML.dump(JSON.parse(JSON.generate(dag)))
|
|
24
|
+
parsed = YAML.safe_load(yaml)
|
|
25
|
+
steps = parsed['steps'] || parsed[:steps]
|
|
26
|
+
expect(steps.length).to be >= 2
|
|
27
|
+
first = steps.first.transform_keys(&:to_s)
|
|
28
|
+
expect(first).to include('id', 'tool', 'args', 'side_effect', 'dependencies')
|
|
29
|
+
expect(%w[read_only active_scan exploit destructive]).to include(first['side_effect'])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'skips completed steps after a killed executor is resumed' do
|
|
33
|
+
Dir.mktmpdir('pwn-dag-') do |dir|
|
|
34
|
+
log = File.join(dir, 'log.txt')
|
|
35
|
+
dag = {
|
|
36
|
+
'version' => 1,
|
|
37
|
+
'request' => 'two-step',
|
|
38
|
+
'steps' => [
|
|
39
|
+
{ 'id' => 's1', 'tool' => 'shell', 'args' => { 'command' => "printf first >> #{log}" }, 'side_effect' => 'read_only', 'dependencies' => [] },
|
|
40
|
+
{ 'id' => 's2', 'tool' => 'shell', 'args' => { 'command' => "printf second >> #{log}" }, 'side_effect' => 'read_only', 'dependencies' => ['s1'] }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
44
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/shell.rb'
|
|
45
|
+
run_id = 'kill-resume'
|
|
46
|
+
pid = Process.fork do
|
|
47
|
+
described_class.execute(dag: dag, run_id: run_id, root: dir, halt_after: 's1')
|
|
48
|
+
exit!
|
|
49
|
+
end
|
|
50
|
+
begin
|
|
51
|
+
Timeout.timeout(10) do
|
|
52
|
+
Process.wait(pid)
|
|
53
|
+
end
|
|
54
|
+
rescue Timeout::Error
|
|
55
|
+
Process.kill('KILL', pid)
|
|
56
|
+
Process.wait(pid)
|
|
57
|
+
end
|
|
58
|
+
expect(File.read(log)).to eq('first')
|
|
59
|
+
described_class.execute(run_id: run_id, root: dir)
|
|
60
|
+
expect(File.read(log)).to eq('firstsecond')
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -29,6 +29,38 @@ describe PWN::AI::Agent::ToolGuard do
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
describe '.placeholder?' do
|
|
32
|
+
it 'ignores shell multiline quoted data and tab-stripped or quoted-delimiter heredocs' do
|
|
33
|
+
["printf '%s' '\n...\n'", "printf '%s' \"\n…\n\"", "cat <<-'MD'\n...\n\tMD\n", "cat <<'DOC-END'\n...\nDOC-END\n", "cat <<A <<\"B\"\n...\nA\n…\nB\n"].each do |text|
|
|
34
|
+
expect(described_class.placeholder?(text: text, language: :shell)).to eq(false), text
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'uses Ruby tokens to distinguish literal content and real syntax from code stubs' do
|
|
39
|
+
["File.write(path, \"line\n...\n\")", "File.write(path, %q{\n...\n})", "File.write(path, <<~DOC)\n ...\nDOC\n", 'x = 1...3', 'def foo(...); bar(...); end'].each do |text|
|
|
40
|
+
expect(described_class.placeholder?(text: text, language: :ruby)).to eq(false), text
|
|
41
|
+
end
|
|
42
|
+
['def foo(<3dots>); end', "def foo(...)\n", "def foo\n...\nend"].each do |text|
|
|
43
|
+
expect(described_class.placeholder?(text: text, language: :ruby)).to eq(true), text
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'keeps comments and escaped shell words as data but detects code after a heredoc' do
|
|
48
|
+
["# ...\nprintf ok", 'printf %s \\...', 'cat <<< "..."', "cat <<D\\OC\n...\nDOC\n"].each do |text|
|
|
49
|
+
expect(described_class.placeholder?(text: text)).to eq(false), text
|
|
50
|
+
end
|
|
51
|
+
['printf ok; ...', "cat <<-'EOF'\n...\n\tEOF\n...", '…', '<...>'].each do |text|
|
|
52
|
+
expect(described_class.placeholder?(text: text)).to eq(true), text
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'locates Ruby stubs in bytes without selecting ellipses from literal tokens' do
|
|
57
|
+
text = 'doc = %q{é ...}; def foo(<3dots>); end'
|
|
58
|
+
match = described_class.placeholder_match(text: text, language: :ruby)
|
|
59
|
+
expect(match[:offset]).to eq(text.b.index('<3dots>'))
|
|
60
|
+
expect(text.byteslice(match[:offset], match[:match].bytesize)).to eq('<3dots>')
|
|
61
|
+
expect(described_class.placeholder?(text: 'def foo(<3dots>); end', language: :ruby, placeholder_ok: true)).to eq(false)
|
|
62
|
+
end
|
|
63
|
+
|
|
32
64
|
it 'matches ellipsis placeholders' do
|
|
33
65
|
expect(described_class.placeholder?(text: '...')).to be true
|
|
34
66
|
expect(described_class.placeholder?(text: '{...}')).to be true
|
|
@@ -56,6 +88,13 @@ describe PWN::AI::Agent::ToolGuard do
|
|
|
56
88
|
end
|
|
57
89
|
|
|
58
90
|
describe '.coerce_args' do
|
|
91
|
+
it 'locates the actual unsupported shell syntax after quoted UTF-8 literals' do
|
|
92
|
+
text = "printf 'é PIPESTATUS'; echo ${PIPESTATUS[0]}"
|
|
93
|
+
matched = described_class.bashism_match(text: text)
|
|
94
|
+
expect(matched).to include(rule_id: 'payload.shell_syntax', match: 'PIPESTATUS')
|
|
95
|
+
expect(matched[:offset]).to eq(text.b.rindex('PIPESTATUS'))
|
|
96
|
+
end
|
|
97
|
+
|
|
59
98
|
it 'aliases value onto the first required key' do
|
|
60
99
|
args = described_class.coerce_args(args: { value: 'id' }, required: %w[command])
|
|
61
100
|
expect(args[:command]).to eq('id')
|
|
@@ -82,6 +121,15 @@ describe PWN::AI::Agent::ToolGuard do
|
|
|
82
121
|
out = described_class.invalid_payload(hint: 'ellipsis', offending_token: '...', text: 'foo ... bar')
|
|
83
122
|
expect(out[:byte_range]).to eq([4, 7])
|
|
84
123
|
end
|
|
124
|
+
|
|
125
|
+
it 'returns a matched substring, byte offset and actionable remedy for UTF-8 payloads' do
|
|
126
|
+
text = 'é …'
|
|
127
|
+
out = described_class.invalid_payload(hint: 'ellipsis', offending_token: '…', text: text)
|
|
128
|
+
expect(out[:match]).to eq('…')
|
|
129
|
+
expect(out[:offset]).to eq('é '.bytesize)
|
|
130
|
+
expect(text.byteslice(out[:offset], out[:match].bytesize)).to eq(out[:match])
|
|
131
|
+
expect(out[:remedy]).not_to be_empty
|
|
132
|
+
end
|
|
85
133
|
end
|
|
86
134
|
|
|
87
135
|
describe '.host_load / .deadline_s' do
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
describe 'PWN::AI::Agent::Tools sandbox strict' do
|
|
8
|
+
before(:all) do
|
|
9
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
10
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/shell.rb'
|
|
11
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/ruby_eval.rb'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'raises a sandbox violation for shell rm and pwn_eval FileUtils.rm_rf without touching disk' do
|
|
15
|
+
root = '/tmp/fake_root'
|
|
16
|
+
FileUtils.mkdir_p(root)
|
|
17
|
+
marker = File.join(root, 'keep')
|
|
18
|
+
File.write(marker, 'safe')
|
|
19
|
+
begin
|
|
20
|
+
stub_const('PWN::Env', { ai_sandbox: 'strict' })
|
|
21
|
+
shell = PWN::AI::Agent::Dispatch.call(
|
|
22
|
+
tool_call: { function: { name: 'shell', arguments: JSON.generate(command: 'rm -rf /tmp/fake_root') } },
|
|
23
|
+
scope_path: File.join(Dir.tmpdir, 'absent.yaml')
|
|
24
|
+
)
|
|
25
|
+
ruby = PWN::AI::Agent::Dispatch.call(
|
|
26
|
+
tool_call: { function: { name: 'pwn_eval', arguments: JSON.generate(code: "FileUtils.rm_rf('/tmp/fake_root')") } },
|
|
27
|
+
scope_path: File.join(Dir.tmpdir, 'absent.yaml')
|
|
28
|
+
)
|
|
29
|
+
expect(JSON.parse(shell, symbolize_names: true).fetch(:result)[:error].to_s).to match(/sandbox violation/i)
|
|
30
|
+
expect(JSON.parse(ruby, symbolize_names: true).fetch(:result)[:error].to_s).to match(/sandbox violation/i)
|
|
31
|
+
expect(File.file?(marker)).to be true
|
|
32
|
+
ensure
|
|
33
|
+
FileUtils.rm_rf(root)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'open3'
|
|
4
7
|
|
|
5
8
|
describe 'PWN::AI::Agent::Tools binary_triage' do
|
|
6
9
|
before(:all) do
|
|
@@ -11,4 +14,22 @@ describe 'PWN::AI::Agent::Tools binary_triage' do
|
|
|
11
14
|
it 'registers expected tool names' do
|
|
12
15
|
expect(PWN::AI::Agent::Registry.lookup(name: 'binary_triage')).not_to be_nil
|
|
13
16
|
end
|
|
17
|
+
|
|
18
|
+
it 'returns mitigations and attack surface from one Dispatch call on a stripped ELF' do
|
|
19
|
+
Dir.mktmpdir('pwn-triage-tool-') do |dir|
|
|
20
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
21
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
22
|
+
source = File.join(dir, 'fixture.c')
|
|
23
|
+
path = File.join(dir, 'fixture')
|
|
24
|
+
File.write(source, "#include <stdio.h>\nint main(void) { puts(\"https://example.test\"); return 0; }\n")
|
|
25
|
+
out, status = Open3.capture2e('cc', '-O0', '-fstack-protector-all', '-o', path, source)
|
|
26
|
+
expect(status.success?).to eq(true), out
|
|
27
|
+
Open3.capture2e('strip', '--strip-all', path)
|
|
28
|
+
raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'binary_triage', arguments: JSON.generate(path: path, session_id: 'fixture') } }, scope_path: File.join(dir, 'absent.yaml'))
|
|
29
|
+
row = JSON.parse(raw, symbolize_names: true).fetch(:result)
|
|
30
|
+
expect(row[:protections]).to include(:nx, :pie, :relro, :canary, :cfi)
|
|
31
|
+
expect(row[:handle].to_s).to include('fixture/')
|
|
32
|
+
expect(JSON.generate(row[:interesting_strings])).to include('https://example.test')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
14
35
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe 'PWN::AI::Agent::Tools browser_goto' do
|
|
8
|
+
before(:all) do
|
|
9
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
10
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/browser_goto.rb'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'registers expected tool names' do
|
|
14
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'browser_goto')).not_to be_nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'records an auth-bypass finding with pixel and HAR proof in one Dispatch call' do
|
|
18
|
+
Dir.mktmpdir('pwn-tb-tool-') do |dir|
|
|
19
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
20
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
21
|
+
stub_const('PWN::Plugins::Findings::FILE', File.join(dir, 'findings.jsonl'))
|
|
22
|
+
har = JSON.generate(
|
|
23
|
+
'log' => {
|
|
24
|
+
'version' => '1.2',
|
|
25
|
+
'creator' => { 'name' => 'fixture', 'version' => '1' },
|
|
26
|
+
'entries' => [{ 'request' => { 'method' => 'GET', 'url' => 'https://app.example.test/admin' } }]
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
raw = PWN::AI::Agent::Dispatch.call(
|
|
30
|
+
tool_call: {
|
|
31
|
+
function: {
|
|
32
|
+
name: 'browser_goto',
|
|
33
|
+
arguments: JSON.generate(
|
|
34
|
+
url: 'https://app.example.test/admin',
|
|
35
|
+
title: 'Auth bypass',
|
|
36
|
+
severity: 'high',
|
|
37
|
+
html: '<html><h1>Dashboard</h1></html>',
|
|
38
|
+
har: har,
|
|
39
|
+
session_id: 'web'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
scope_path: File.join(dir, 'absent.yaml')
|
|
44
|
+
)
|
|
45
|
+
row = JSON.parse(raw, symbolize_names: true).fetch(:result)
|
|
46
|
+
finding = row[:finding]
|
|
47
|
+
expect(finding[:title]).to match(/auth bypass/i)
|
|
48
|
+
arts = Array(finding[:poc_artifacts]) + Array(finding[:evidence_artifacts]).flat_map { |item| [item[:path], item[:stored]] }
|
|
49
|
+
shot = arts.find { |path| File.file?(path.to_s) && File.binread(path).b.start_with?("\x89PNG".b) }
|
|
50
|
+
har_path = arts.find { |path| File.file?(path.to_s) && File.read(path).include?('/admin') }
|
|
51
|
+
expect(shot).not_to be_nil
|
|
52
|
+
expect(har_path).not_to be_nil
|
|
53
|
+
stored = PWN::Plugins::Findings.report
|
|
54
|
+
expect(stored.first[:title]).to match(/auth bypass/i)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'open3'
|
|
4
7
|
|
|
5
8
|
describe 'PWN::AI::Agent::Tools exploitdev' do
|
|
6
9
|
before(:all) do
|
|
@@ -11,4 +14,43 @@ describe 'PWN::AI::Agent::Tools exploitdev' do
|
|
|
11
14
|
it 'registers expected tool names' do
|
|
12
15
|
expect(PWN::AI::Agent::Registry.lookup(name: 'exploitdev')).not_to be_nil
|
|
13
16
|
end
|
|
17
|
+
|
|
18
|
+
it 'builds a ret2libc chain end-to-end through Dispatch' do
|
|
19
|
+
Dir.mktmpdir('pwn-ret2libc-tool-') do |dir|
|
|
20
|
+
src = File.join(dir, 'vuln.c')
|
|
21
|
+
bin = File.join(dir, 'vuln')
|
|
22
|
+
File.write(src, <<~C)
|
|
23
|
+
#include <stdio.h>
|
|
24
|
+
#include <stdlib.h>
|
|
25
|
+
#include <unistd.h>
|
|
26
|
+
char cmd[] = "echo RET2LIBC_OK";
|
|
27
|
+
void keep(void) { __asm__ volatile("pop %rdi; ret"); }
|
|
28
|
+
int main(int argc, char **argv) {
|
|
29
|
+
char buf[64];
|
|
30
|
+
(void)argv;
|
|
31
|
+
if (argc > 100) { system(cmd); keep(); }
|
|
32
|
+
read(0, buf, 256);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
C
|
|
36
|
+
out, status = Open3.capture2e('cc', '-O0', '-fno-stack-protector', '-no-pie', '-o', bin, src)
|
|
37
|
+
expect(status.success?).to eq(true), out
|
|
38
|
+
raw = PWN::AI::Agent::Dispatch.call(
|
|
39
|
+
tool_call: { function: { name: 'exploitdev', arguments: JSON.generate(action: 'ret2libc', path: bin, offset: 72, extra: 'cmd') } },
|
|
40
|
+
scope_path: File.join(dir, 'absent.yaml')
|
|
41
|
+
)
|
|
42
|
+
row = JSON.parse(raw, symbolize_names: true).fetch(:result)
|
|
43
|
+
expect(row[:payload]).not_to be_empty
|
|
44
|
+
expect(row[:plt].keys.map(&:to_s)).to include('system')
|
|
45
|
+
expect(row[:pop_rdi]).to be_a(Integer)
|
|
46
|
+
packed = [row[:payload_hex] || row['payload_hex']].pack('H*')
|
|
47
|
+
packed = row[:payload].to_s if packed.empty?
|
|
48
|
+
io = PWN::Plugins::ExploitDev.io(cmd: [bin], pty: false)
|
|
49
|
+
PWN::Plugins::ProcessTube.send_raw(id: io[:id], bytes: packed)
|
|
50
|
+
out = PWN::Plugins::ProcessTube.recvuntil(id: io[:id], until: 'RET2LIBC_OK', timeout: 3)
|
|
51
|
+
expect(out.to_s).to include('RET2LIBC_OK')
|
|
52
|
+
ensure
|
|
53
|
+
PWN::Plugins::ProcessTube.close(id: io[:id]) if defined?(io) && io
|
|
54
|
+
end
|
|
55
|
+
end
|
|
14
56
|
end
|