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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require 'time'
7
+ require 'securerandom'
8
+
9
+ module PWN
10
+ module AI
11
+ module Agent
12
+ # Planner/executor split: YAML task DAGs with per-step checkpoints.
13
+ module TaskDAG
14
+ ROOT = File.join(Dir.home, '.pwn', 'runs')
15
+
16
+ public_class_method def self.required_bins
17
+ []
18
+ end
19
+
20
+ # Emit a YAML-serializable DAG with zero tool side effects.
21
+ public_class_method def self.plan(opts = {})
22
+ request = opts[:request].to_s
23
+ raise 'ERROR: request is required' if request.strip.empty?
24
+
25
+ steps = Array(opts[:steps])
26
+ steps = infer_steps(request: request) if steps.empty?
27
+ {
28
+ version: 1,
29
+ request: request,
30
+ steps: steps.map { |step| normalize_step(step: step) }
31
+ }
32
+ end
33
+
34
+ # Run an approved DAG; completed checkpoints are skipped on resume.
35
+ public_class_method def self.execute(opts = {})
36
+ root = opts[:root] || ROOT
37
+ run_id = (opts[:run_id] || SecureRandom.hex(8)).to_s
38
+ dir = File.join(root, run_id)
39
+ FileUtils.mkdir_p(dir)
40
+ dag = load_dag(opts.merge(dir: dir))
41
+ File.write(File.join(dir, 'dag.yaml'), YAML.dump(JSON.parse(JSON.generate(dag)))) unless File.file?(File.join(dir, 'dag.yaml'))
42
+ File.write(File.join(dir, 'run_id'), run_id)
43
+ done = load_done(dir: dir)
44
+ steps = Array(dag['steps'] || dag[:steps]).map { |step| normalize_step(step: step) }
45
+ results = []
46
+ remaining = steps.reject { |step| done[step['id']] }
47
+ until remaining.empty?
48
+ ready = remaining.select { |step| Array(step['dependencies']).all? { |need| done[need] } }
49
+ raise ArgumentError, 'task DAG deadlock or missing dependencies' if ready.empty?
50
+
51
+ ready.each do |step|
52
+ remaining.delete(step)
53
+ row = run_step(step: step, dir: dir)
54
+ done[step['id']] = true
55
+ results << row
56
+ persist_checkpoint(dir: dir, step: step, row: row)
57
+ return { run_id: run_id, dir: dir, results: results, halted: step['id'] } if opts[:halt_after].to_s == step['id']
58
+ end
59
+ end
60
+ { run_id: run_id, dir: dir, results: results, ok: results.all? { |row| row[:ok] != false } }
61
+ end
62
+
63
+ # Resume a checkpointed run, skipping completed steps.
64
+ public_class_method def self.resume(opts = {})
65
+ execute(opts)
66
+ end
67
+
68
+ public_class_method def self.authors
69
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
70
+ end
71
+
72
+ public_class_method def self.help
73
+ puts "USAGE:
74
+ # List host binaries this module expects to be installed.
75
+ #{self}.required_bins
76
+
77
+ # Emit a YAML-serializable DAG with zero tool side effects.
78
+ #{self}.plan(
79
+ request: 'required - operator request to decompose',
80
+ steps: 'optional - Array of step hashes; inferred from the request when omitted'
81
+ )
82
+
83
+ # Run an approved DAG; completed checkpoints are skipped on resume.
84
+ #{self}.execute(
85
+ dag: 'optional - Hash DAG; required unless path or run_id is set',
86
+ path: 'optional - YAML DAG path',
87
+ run_id: 'optional - existing run to resume; generated when omitted',
88
+ root: 'optional - runs directory; defaults to ~/.pwn/runs',
89
+ halt_after: 'optional - step id used by tests to stop after a checkpoint'
90
+ )
91
+
92
+ # Resume a checkpointed run, skipping completed steps.
93
+ #{self}.resume(
94
+ run_id: 'required - run id previously returned by execute',
95
+ root: 'optional - runs directory; defaults to ~/.pwn/runs'
96
+ )
97
+
98
+ # Print the AUTHOR(S) string for this module.
99
+ #{self}.authors
100
+ "
101
+ constants.sort
102
+ end
103
+
104
+ private_class_method def self.infer_steps(opts = {})
105
+ parts = opts[:request].to_s.split(/\s+then\s+|;\s*|\n+/i).map(&:strip).reject(&:empty?)
106
+ parts = [opts[:request].to_s.strip] if parts.empty?
107
+ prev = nil
108
+ parts.each_with_index.map do |clause, idx|
109
+ id = "s#{idx + 1}"
110
+ tool, args = map_clause(clause: clause)
111
+ step = {
112
+ 'id' => id,
113
+ 'tool' => tool,
114
+ 'args' => args,
115
+ 'side_effect' => side_effect_tier(tool: tool, args: args),
116
+ 'dependencies' => prev ? [prev] : []
117
+ }
118
+ prev = id
119
+ step
120
+ end
121
+ end
122
+
123
+ private_class_method def self.map_clause(opts = {})
124
+ clause = opts[:clause].to_s
125
+ case clause
126
+ when /\bnmap\b|\bnmap_scan\b/i
127
+ ['nmap_scan', { 'xml' => clause }]
128
+ when /\bnuclei\b/i
129
+ ['nuclei_scan', { 'target' => clause }]
130
+ when /\b(pwn_eval|ruby)\b/i
131
+ ['pwn_eval', { 'code' => clause }]
132
+ else
133
+ ['shell', { 'command' => clause }]
134
+ end
135
+ end
136
+
137
+ private_class_method def self.side_effect_tier(opts = {})
138
+ tool = opts[:tool].to_s
139
+ args = opts[:args] || {}
140
+ payload = args['command'] || args[:command] || args['code'] || args[:code] || JSON.generate(args)
141
+ kind = PWN::Plugins::AISandbox.classify(payload: payload, side_effect: opts[:side_effect])
142
+ case kind.to_s
143
+ when 'write' then 'destructive'
144
+ when 'network' then 'active_scan'
145
+ else
146
+ tool.match?(/scan|nuclei|nmap|fuzz/) ? 'active_scan' : 'read_only'
147
+ end
148
+ end
149
+
150
+ private_class_method def self.normalize_step(opts = {})
151
+ step = opts[:step]
152
+ step = step.transform_keys(&:to_s) if step.is_a?(Hash)
153
+ raise ArgumentError, 'each step needs id, tool, and args' unless step.is_a?(Hash)
154
+
155
+ args = step['args'] || {}
156
+ args = args.transform_keys(&:to_s) if args.is_a?(Hash)
157
+ {
158
+ 'id' => step['id'].to_s,
159
+ 'tool' => step['tool'].to_s,
160
+ 'args' => args,
161
+ 'side_effect' => (step['side_effect'] || side_effect_tier(tool: step['tool'], args: args)).to_s,
162
+ 'dependencies' => Array(step['dependencies']).map(&:to_s)
163
+ }
164
+ end
165
+
166
+ private_class_method def self.load_dag(opts = {})
167
+ if opts[:dag]
168
+ dag = opts[:dag]
169
+ return dag.transform_keys(&:to_s) if dag.is_a?(Hash)
170
+
171
+ return dag
172
+ end
173
+ path = opts[:path].to_s
174
+ path = File.join(opts[:dir], 'dag.yaml') if path.empty?
175
+ raise 'ERROR: dag, path, or an existing run is required' unless File.file?(path)
176
+
177
+ YAML.safe_load_file(path)
178
+ end
179
+
180
+ private_class_method def self.load_done(opts = {})
181
+ path = File.join(opts[:dir], 'checkpoints.jsonl')
182
+ return {} unless File.file?(path)
183
+
184
+ File.readlines(path).each_with_object({}) do |line, acc|
185
+ row = JSON.parse(line)
186
+ acc[row['id']] = true if row['status'] == 'completed'
187
+ rescue JSON::ParserError
188
+ next
189
+ end
190
+ end
191
+
192
+ private_class_method def self.persist_checkpoint(opts = {})
193
+ path = File.join(opts[:dir], 'checkpoints.jsonl')
194
+ rec = { id: opts[:step]['id'], status: 'completed', at: Time.now.utc.iso8601, result: opts[:row] }
195
+ File.open(path, 'a') do |file|
196
+ file.flock(File::LOCK_EX)
197
+ file.puts(JSON.generate(rec))
198
+ file.flush
199
+ file.fsync
200
+ end
201
+ end
202
+
203
+ private_class_method def self.run_step(opts = {})
204
+ step = opts[:step]
205
+ PWN::AI::Agent::Registry.discover
206
+ raw = PWN::AI::Agent::Dispatch.call(
207
+ tool_call: { function: { name: step['tool'], arguments: JSON.generate(step['args']) } },
208
+ scope_path: File.join(opts[:dir], 'absent.yaml')
209
+ )
210
+ body = JSON.parse(raw, symbolize_names: true)
211
+ result = body[:result] || body
212
+ ok = !result.is_a?(Hash) || result[:error].nil?
213
+ { id: step['id'], ok: ok, result: result }
214
+ rescue StandardError => e
215
+ { id: step['id'], ok: false, error: "#{e.class}: #{e.message}" }
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -8,6 +8,8 @@ require 'time'
8
8
  require 'openssl'
9
9
  require 'uri'
10
10
  require 'base64'
11
+ require 'ripper'
12
+ require 'strscan'
11
13
 
12
14
  module PWN
13
15
  module AI
@@ -59,36 +61,155 @@ module PWN
59
61
  end
60
62
 
61
63
  public_class_method def self.placeholder?(opts = {})
62
- return false if opts[:placeholder_ok] == true
63
-
64
- raw = opts[:text].to_s.dup
65
- s = raw.dup
66
- s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m, ' ')
67
- s.gsub!(/<<[-~]?\s*(\w+).*?^\1\s*$/m, ' ')
68
- return true if s.lines.any? { |ln| ln.match?(/^\s*(?:\.{3}|…)\s*$/) }
69
- return true if s.strip.match?(/\A\[\s*(?:\.{3}|…)\s*\]\z/)
70
-
71
- s.gsub!(/'[^']*'/, "''")
72
- s.gsub!(/"([^"\\]|\\.)*"/, '""')
73
- PLACEHOLDER_RX.match?(s)
64
+ !placeholder_match(text: opts[:text], language: opts[:language], placeholder_ok: opts[:placeholder_ok]).nil?
65
+ end
66
+
67
+ public_class_method def self.placeholder_match(opts = {})
68
+ return nil if opts[:placeholder_ok] == true
69
+
70
+ raw = opts[:text].to_s.dup.force_encoding(Encoding::UTF_8)
71
+ return ruby_placeholder_match(text: raw) if opts[:language].to_s == 'ruby'
72
+
73
+ s = shell_code_surface(text: raw)
74
+ found = /(?:\A|[;\n|&()])\s*(?<token>\.{3}|…)\s*(?=\z|[;\n|&()])/.match(s) || /\A\s*\[\s*(?<token>\.{3}|…)\s*\]\s*\z/.match(s)
75
+ if found
76
+ index = found.begin(:token)
77
+ token = found[:token]
78
+ else
79
+ found = PLACEHOLDER_RX.match(s)
80
+ return nil unless found
81
+
82
+ token = found[0].strip
83
+ index = found.begin(0) + found[0].index(token)
84
+ end
85
+ offset = s[0...index].bytesize
86
+ { rule_id: 'payload.placeholder', match: raw.byteslice(offset, token.bytesize), offset: offset }
74
87
  rescue StandardError
75
- false
88
+ nil
89
+ end
90
+
91
+ private_class_method def self.ruby_placeholder_match(opts = {})
92
+ raw = opts[:text]
93
+ # Parsed ranges and argument forwarding are real Ruby, not elision.
94
+ return nil if Ripper.sexp(raw)
95
+
96
+ lines = raw.lines
97
+ tokens = Ripper.lex(raw).reject { |_, type, _, _| %i[on_sp on_nl on_ignored_nl on_comment].include?(type) }
98
+ tokens.each_with_index do |(position, type, text, _), index|
99
+ next unless %i[on_op on_ident].include?(type)
100
+
101
+ marker = text if %w[... …].include?(text)
102
+ following = tokens[index, 4]
103
+ marker = '<3dots>' if following.map { |row| row[2] } == ['<', '3', 'dots', '>']
104
+ next unless marker
105
+
106
+ offset = lines.first(position[0] - 1).sum(&:bytesize) + position[1]
107
+ return { rule_id: 'payload.placeholder', match: marker, offset: offset }
108
+ end
109
+ nil
110
+ end
111
+
112
+ # Shlex-style word scanning with quote removal and queued heredocs.
113
+ # Mask literal bytes, retaining newlines and original byte positions.
114
+ private_class_method def self.shell_code_surface(opts = {})
115
+ raw = opts[:text].to_s
116
+ scanner = StringScanner.new(raw)
117
+ surface = raw.b.dup
118
+ pending = []
119
+ mask = lambda do |start, finish|
120
+ surface[start...finish] = raw.byteslice(start...finish).b.gsub(/[^\n]/n, '_')
121
+ end
122
+ until scanner.eos?
123
+ next if scanner.scan(/[ \t\r]+/)
124
+
125
+ if scanner.scan("\n")
126
+ pending.each do |delimiter, tabs|
127
+ until scanner.eos?
128
+ start = scanner.pos
129
+ line = scanner.scan(/[^\n]*(?:\n|\z)/)
130
+ mask.call(start, scanner.pos)
131
+ value = line.delete_suffix("\n")
132
+ value = value.sub(/\A\t+/, '') if tabs
133
+ break if value == delimiter
134
+ end
135
+ end
136
+ pending.clear
137
+ elsif scanner.peek(1) == '#'
138
+ start = scanner.pos
139
+ scanner.scan(/[^\n]*/)
140
+ mask.call(start, scanner.pos)
141
+ elsif scanner.peek(3) == '<<<'
142
+ start = scanner.pos
143
+ scanner.scan('<<<')
144
+ scanner.scan(/[ \t]*/)
145
+ shell_word(scanner: scanner)
146
+ mask.call(start, scanner.pos)
147
+ elsif scanner.peek(2) == '<<'
148
+ start = scanner.pos
149
+ operator = scanner.scan(/<<-?/)
150
+ scanner.scan(/[ \t]*/)
151
+ word = shell_word(scanner: scanner)
152
+ pending << [word[:text], operator == '<<-'] unless word[:text].empty?
153
+ mask.call(start, scanner.pos)
154
+ elsif scanner.scan(/[;|&()<>]/)
155
+ next
156
+ else
157
+ start = scanner.pos
158
+ word = shell_word(scanner: scanner)
159
+ mask.call(start, scanner.pos) if word[:quoted]
160
+ end
161
+ end
162
+ surface.force_encoding(raw.encoding)
163
+ end
164
+
165
+ private_class_method def self.shell_word(opts = {})
166
+ scanner = opts[:scanner]
167
+ text = +''
168
+ quoted = false
169
+ until scanner.eos? || scanner.peek(1).b.match?(/[\s;|&()<>]/n)
170
+ char = scanner.getch
171
+ if ["'", '"'].include?(char)
172
+ quoted = true
173
+ quote = char
174
+ until scanner.eos?
175
+ char = scanner.getch
176
+ break if char == quote
177
+
178
+ char = scanner.getch if char == '\\' && quote == '"' && !scanner.eos?
179
+ text << char
180
+ end
181
+ elsif char == '\\' && !scanner.eos?
182
+ quoted = true
183
+ text << scanner.getch
184
+ else
185
+ text << char
186
+ end
187
+ end
188
+ { text: text, quoted: quoted }
76
189
  end
77
190
 
78
191
  public_class_method def self.bashism?(opts = {})
192
+ !bashism_match(text: opts[:text]).nil?
193
+ end
194
+
195
+ public_class_method def self.bashism_match(opts = {})
79
196
  surface = shell_syntax_surface(text: opts[:text])
80
- return false if surface.to_s.empty?
197
+ return nil if surface.to_s.empty?
81
198
 
82
- surface = surface.gsub(/\$\{?RANDOM\}?\b/, '') if surface.match?(/\bRANDOM=/)
83
- BASHISM_RX.match?(surface)
199
+ surface = surface.gsub(/\$\{?RANDOM\}?\b/) { |token| ' ' * token.length } if surface.match?(/\bRANDOM=/)
200
+ found = BASHISM_RX.match(surface)
201
+ return nil unless found
202
+
203
+ raw = opts[:text].to_s
204
+ { rule_id: 'payload.shell_syntax', match: raw[found.begin(0), found[0].length], offset: raw[0...found.begin(0)].bytesize }
84
205
  rescue StandardError
85
- false
206
+ nil
86
207
  end
87
208
 
88
209
  public_class_method def self.shell_syntax_surface(opts = {})
89
210
  s = opts[:text].to_s.dup
90
- s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m, ' ')
91
- s.gsub!(/'[^']*'/, "''")
211
+ s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m) { |body| ' ' * body.length }
212
+ s.gsub!(/'[^']*'/) { |body| "'#{' ' * (body.length - 2)}'" }
92
213
  s
93
214
  end
94
215
 
@@ -217,13 +338,15 @@ module PWN
217
338
 
218
339
  public_class_method def self.invalid_payload(opts = {})
219
340
  hint = opts[:hint].to_s
220
- tok = opts[:offending_token].to_s
341
+ tok = (opts[:match] || opts[:offending_token]).to_s
221
342
  text = opts[:text].to_s
222
343
  range = opts[:byte_range]
223
344
  if range.nil? && !tok.empty? && !text.empty?
224
- idx = text.index(tok)
345
+ idx = opts[:offset] || text.b.index(tok.b)
225
346
  range = [idx, idx + tok.bytesize] if idx
226
347
  end
348
+ remedy = (opts[:remedy] || opts[:suggestion]).to_s
349
+ remedy = "Correct the payload before retrying. #{hint}" if remedy.empty?
227
350
  {
228
351
  stdout: '',
229
352
  stderr: hint,
@@ -231,10 +354,13 @@ module PWN
231
354
  error: 'invalid_payload',
232
355
  code: (opts[:code] || 'SYNTAX_DENY').to_s,
233
356
  rule_id: (opts[:rule_id] || 'payload').to_s,
357
+ match: tok.empty? ? nil : tok,
358
+ offset: range&.first,
359
+ remedy: remedy,
234
360
  offending_token: tok,
235
361
  byte_range: range,
236
362
  max_payload_bytes: MAX_PAYLOAD_BYTES,
237
- suggestion: opts[:suggestion].to_s,
363
+ suggestion: remedy,
238
364
  hint: hint,
239
365
  shell: opts[:shell] || shell_name
240
366
  }
@@ -631,7 +757,15 @@ module PWN
631
757
  # Detect isolated ellipsis placeholders, not inline ranges or prose.
632
758
  #{self}.placeholder?(
633
759
  text: 'optional - command or payload text to inspect',
634
- placeholder_ok: 'optional - true skips denial when the operator opts in'
760
+ placeholder_ok: 'optional - true skips denial when the operator opts in',
761
+ language: 'optional - ruby uses Ripper; shell (default) scans quotes and heredocs'
762
+ )
763
+
764
+ # Locate a rejected placeholder in the original decoded payload.
765
+ #{self}.placeholder_match(
766
+ text: 'optional - command or payload text to inspect',
767
+ placeholder_ok: 'optional - true returns no match for intentional literal content',
768
+ language: 'optional - ruby uses Ripper; shell (default) scans quotes and heredocs'
635
769
  )
636
770
 
637
771
  # Run bashism and return its result
@@ -639,6 +773,11 @@ module PWN
639
773
  text: 'optional - text value consumed by #bashism?'
640
774
  )
641
775
 
776
+ # Locate unsupported shell syntax in the original decoded payload.
777
+ #{self}.bashism_match(
778
+ text: 'optional - shell command to inspect for bash-only constructs'
779
+ )
780
+
642
781
  # Strip quoted heredocs and single-quoted strings before bash-syntax lint.
643
782
  #{self}.shell_syntax_surface(
644
783
  text: 'required - command string to lint'
@@ -693,7 +832,10 @@ module PWN
693
832
  offending_token: 'optional - exact rejected token span',
694
833
  suggestion: 'optional - how to rewrite the payload',
695
834
  text: 'optional - original payload used to compute byte_range',
696
- byte_range: 'optional - [start, stop] byte offsets of the offending span'
835
+ byte_range: 'optional - [start, stop] byte offsets of the offending span',
836
+ match: 'optional - exact matched substring; alias for offending_token',
837
+ offset: 'optional - zero-based byte offset in the original decoded payload',
838
+ remedy: 'optional - sanctioned correction; alias for suggestion'
697
839
  )
698
840
 
699
841
  # Build a machine-readable guard denial (SCOPE_DENY, CANARY_DENY, ...).
@@ -38,32 +38,62 @@ PWN::AI::Agent::Registry.register(
38
38
  toolset: 'sessions',
39
39
  schema: {
40
40
  name: 'artifact_read',
41
- description: 'Page an artifact as text/hex/base64 from offset.',
41
+ description: 'Read a bounded byte page. Use handle from a spilled tool result, offset and length; follow next_offset until eof. Handle pages default to lossless base64.',
42
42
  parameters: {
43
43
  type: 'object',
44
44
  properties: {
45
+ handle: { type: 'string' },
45
46
  path: { type: 'string' },
46
- offset: { type: 'integer' },
47
- length: { type: 'integer' },
47
+ offset: { type: 'integer', minimum: 0 },
48
+ length: { type: 'integer', minimum: 1 },
48
49
  mode: { type: 'string', description: 'text|hex|base64' },
49
50
  grep: { type: 'string' },
50
51
  ref: { type: 'string' },
51
52
  sha256: { type: 'string' }
52
53
  },
53
- required: %w[path]
54
+ anyOf: [{ required: %w[handle] }, { required: %w[path] }, { required: %w[ref] }]
54
55
  }
55
56
  },
56
57
  handler: lambda { |args|
57
58
  PWN::Plugins::ArtifactRegistry.read_page(
59
+ handle: args[:handle] || args['handle'],
58
60
  path: args[:path] || args['path'] || args[:ref] || args['ref'],
59
61
  offset: args[:offset] || args['offset'],
60
62
  length: args[:length] || args['length'],
61
- mode: args[:mode] || args['mode'],
63
+ max_length: PWN::AI::Agent::Result.page_length,
64
+ max_bytes: PWN::AI::Agent::Result.page_length,
65
+ mode: args[:mode] || args['mode'] || (args[:handle] || args['handle'] ? 'base64' : 'text'),
62
66
  grep: args[:grep] || args['grep'],
63
67
  sha256: args[:sha256] || args['sha256']
64
68
  )
65
69
  }
66
70
  )
71
+ PWN::AI::Agent::Registry.register(
72
+ name: 'artifact_grep',
73
+ toolset: 'sessions',
74
+ schema: {
75
+ name: 'artifact_grep',
76
+ description: 'Search saved tool output by complete binary lines (first match per line, maximum line 64 MiB). Returns bounded previews and byte offsets; resume next_offset until eof, even after empty matches. Use artifact_read for exact bytes.',
77
+ parameters: {
78
+ type: 'object',
79
+ properties: {
80
+ handle: { type: 'string' },
81
+ regex: { type: 'string' },
82
+ offset: { type: 'integer', minimum: 0 }
83
+ },
84
+ required: %w[handle regex]
85
+ }
86
+ },
87
+ handler: lambda { |args|
88
+ PWN::Plugins::ArtifactRegistry.grep(
89
+ handle: args[:handle] || args['handle'],
90
+ regex: args[:regex] || args['regex'],
91
+ offset: args[:offset] || args['offset'],
92
+ limit: 1,
93
+ max_bytes: PWN::AI::Agent::Result.page_length
94
+ )
95
+ }
96
+ )
67
97
  PWN::AI::Agent::Registry.register(
68
98
  name: 'artifact_put',
69
99
  toolset: 'sessions',
@@ -7,14 +7,18 @@ PWN::AI::Agent::Registry.register(
7
7
  toolset: 'pwn',
8
8
  schema: {
9
9
  name: 'binary_triage',
10
- description: 'Triage an ELF/PE/Mach-O binary (format, arch, imports, exports) and spill JSON to ~/.pwn/artifacts.',
10
+ description: 'Triage an ELF/PE/Mach-O binary into one JSON object: type, arch, linking, NX/PIE/RELRO/canary/CFI, imports/exports, interesting strings, section entropy, packer heuristics. Persists to the artifact store.',
11
11
  parameters: {
12
12
  type: 'object',
13
- properties: { path: { type: 'string', description: 'Filesystem path of the binary.' } },
13
+ properties: {
14
+ path: { type: 'string', description: 'Filesystem path of the binary.' },
15
+ session_id: { type: 'string', description: 'Artifact session id (defaults to triage).' }
16
+ },
14
17
  required: %w[path]
15
18
  }
16
19
  },
17
20
  handler: lambda { |args|
18
- PWN::Plugins::BinaryParser.triage(path: args[:path] || args['path'])
21
+ args = args.transform_keys(&:to_sym)
22
+ PWN::Plugins::BinaryParser.triage(path: args[:path], session_id: args[:session_id] || args[:session] || 'triage')
19
23
  }
20
24
  )
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'browser_goto',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'browser_goto',
10
+ description: 'Navigate a URL and optionally record screenshot, DOM snapshot, and HAR into the artifact store. When title is set, records a finding with pixel and HAR proof in the same call.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ url: { type: 'string', description: 'HTTP(S) URL to open.' },
15
+ uri: { type: 'string' },
16
+ capture: { type: 'boolean', description: 'Spill screenshot, DOM, and HAR (defaults true when title is set).' },
17
+ title: { type: 'string', description: 'Finding title; records pixel and HAR proof without a second call.' },
18
+ severity: { type: 'string' },
19
+ session_id: { type: 'string' },
20
+ engagement_id: { type: 'string' },
21
+ html: { type: 'string', description: 'DOM HTML fixture when no live browser is open.' },
22
+ har: { type: 'string', description: 'HAR JSON fixture when no live capture proxy is attached.' },
23
+ screenshot: { type: 'string', description: 'PNG path used when no live screenshot is available.' },
24
+ label: { type: 'string' }
25
+ }
26
+ }
27
+ },
28
+ handler: lambda { |args|
29
+ args = args.transform_keys(&:to_sym)
30
+ PWN::Plugins::TransparentBrowser.goto(args)
31
+ }
32
+ )
@@ -26,6 +26,29 @@ PWN::AI::Agent::Registry.register(
26
26
  )
27
27
  }
28
28
  )
29
+ PWN::AI::Agent::Registry.register(
30
+ name: 'gdb_run_to_crash',
31
+ toolset: 'pwn',
32
+ schema: {
33
+ name: 'gdb_run_to_crash',
34
+ description: 'Run a binary under gdb MI3 until crash and return {signal, pc, fault_addr, exploitability} plus ExploitDev offset from stdin/payload.',
35
+ parameters: {
36
+ type: 'object',
37
+ properties: {
38
+ binary: { type: 'string' },
39
+ bin: { type: 'string' },
40
+ stdin: { type: 'string', description: 'Crashing input bytes.' },
41
+ payload: { type: 'string' },
42
+ pwndbg: { type: 'boolean' }
43
+ },
44
+ anyOf: [{ required: %w[binary] }, { required: %w[bin] }]
45
+ }
46
+ },
47
+ handler: lambda { |args|
48
+ args = args.transform_keys(&:to_sym)
49
+ PWN::Plugins::GDBMI.run_to_crash(binary: args[:binary] || args[:bin], stdin: args[:stdin] || args[:payload], pwndbg: args[:pwndbg])
50
+ }
51
+ )
29
52
  PWN::AI::Agent::Registry.register(
30
53
  name: 'binary_diff',
31
54
  toolset: 'pwn',