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
@@ -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('truncated')
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