pwn 0.5.719 → 0.5.721

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
  3. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
  4. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +5 -2
  5. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
  7. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +3 -0
  9. data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
  10. data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
  11. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
  14. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  15. data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
  17. data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
  18. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
  21. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  24. data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
  26. data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
  27. data/lib/pwn/ai/agent/dispatch.rb +69 -1
  28. data/lib/pwn/ai/agent/engagement.rb +167 -0
  29. data/lib/pwn/ai/agent/learning.rb +105 -5
  30. data/lib/pwn/ai/agent/loop.rb +83 -25
  31. data/lib/pwn/ai/agent/metrics.rb +35 -1
  32. data/lib/pwn/ai/agent/registry.rb +28 -0
  33. data/lib/pwn/ai/agent/reward.rb +80 -15
  34. data/lib/pwn/ai/agent/swarm.rb +36 -0
  35. data/lib/pwn/ai/agent/tool_guard.rb +93 -9
  36. data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
  37. data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
  38. data/lib/pwn/ai/agent/turn_finalizer.rb +84 -0
  39. data/lib/pwn/ai/agent.rb +1 -0
  40. data/lib/pwn/config.rb +18 -2
  41. data/lib/pwn/plugins/artifact_registry.rb +32 -2
  42. data/lib/pwn/plugins/bin_workspace.rb +81 -0
  43. data/lib/pwn/plugins/binary_parser.rb +27 -0
  44. data/lib/pwn/plugins/capability.rb +68 -0
  45. data/lib/pwn/plugins/detonate.rb +62 -0
  46. data/lib/pwn/plugins/emulator.rb +64 -0
  47. data/lib/pwn/plugins/exploit_dev.rb +33 -0
  48. data/lib/pwn/plugins/findings.rb +56 -1
  49. data/lib/pwn/plugins/gdb.rb +42 -0
  50. data/lib/pwn/plugins/ghidra.rb +20 -2
  51. data/lib/pwn/plugins/packet.rb +26 -0
  52. data/lib/pwn/plugins/preflight_checker.rb +23 -0
  53. data/lib/pwn/plugins/process_tube.rb +31 -1
  54. data/lib/pwn/plugins/repl.rb +25 -2
  55. data/lib/pwn/plugins/vault.rb +116 -0
  56. data/lib/pwn/plugins.rb +4 -0
  57. data/lib/pwn/reports/engagement.rb +63 -0
  58. data/lib/pwn/reports.rb +1 -0
  59. data/lib/pwn/sessions.rb +4 -0
  60. data/lib/pwn/version.rb +1 -1
  61. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
  62. data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
  63. data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
  64. data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
  65. data/spec/lib/pwn/ai/agent/reward_spec.rb +17 -1
  66. data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
  67. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
  68. data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
  69. data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
  70. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
  71. data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
  72. data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
  73. data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
  74. data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
  75. data/spec/lib/pwn/plugins/findings_spec.rb +16 -3
  76. data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
  77. data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
  78. data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
  79. data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
  80. data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
  81. data/third_party/pwn_rdoc.jsonl +73 -0
  82. metadata +25 -1
@@ -99,9 +99,32 @@ module PWN
99
99
  bump(bucket: e, success: success, duration: duration, error: error)
100
100
  end
101
101
  save(metrics: metrics)
102
+ append_jsonl(opts.merge(name: name, success: success, duration: duration, error: error, engine: engine))
102
103
  t
103
104
  end
104
105
 
106
+ public_class_method def self.append_jsonl(opts = {})
107
+ path = File.join(Dir.home, '.pwn', 'logs', 'tool_metrics.jsonl')
108
+ FileUtils.mkdir_p(File.dirname(path))
109
+ row = {
110
+ ts: Time.now.utc.iso8601,
111
+ session: Thread.current[:pwn_session_id],
112
+ tool: opts[:name],
113
+ latency_ms: (opts[:duration].to_f * 1000).round,
114
+ timeout_used: opts[:timeout],
115
+ outcome: opts[:success] ? 'ok' : 'err',
116
+ error_class: opts[:error].to_s.split(':').first,
117
+ bytes_out: opts[:bytes_out].to_i
118
+ }
119
+ File.open(path, 'a') do |f|
120
+ f.flock(File::LOCK_EX)
121
+ f.puts(JSON.generate(row))
122
+ end
123
+ row
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
105
128
  # Supported Method Parameters::
106
129
  # rows = PWN::AI::Agent::Metrics.summary(
107
130
  # limit: 'optional - cap number of tools returned (default 25)',
@@ -664,11 +687,22 @@ module PWN
664
687
  #{self}.record(
665
688
  name: 'required - tool name that was dispatched',
666
689
  success: 'required - Boolean, did the handler complete without error',
667
- duration: 'optional - Float seconds the dispatch took',
690
+ duration: 'optional - Float seconds the dispatch took (wall time)',
668
691
  error: 'optional - String error message when success is false',
669
692
  engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)'
670
693
  )
671
694
 
695
+ # Append one JSONL telemetry row under ~/.pwn/logs/tool_metrics.jsonl.
696
+ #{self}.append_jsonl(
697
+ name: 'required - tool name',
698
+ success: 'optional - Boolean outcome',
699
+ duration: 'optional - Float seconds the dispatch took (wall time)',
700
+ error: 'optional - error string',
701
+ engine: 'optional - engine name',
702
+ timeout: 'optional - timeout used',
703
+ bytes_out: 'optional - output byte count'
704
+ )
705
+
672
706
  # Run summary and return its result
673
707
  #{self}.summary(
674
708
  limit: 'optional - cap number of tools returned (default 25)',
@@ -113,6 +113,7 @@ module PWN
113
113
  # )
114
114
 
115
115
  public_class_method def self.definitions(opts = {})
116
+ eager_load!
116
117
  enabled = opts[:enabled]
117
118
  enabled = enabled.map(&:to_s) if enabled
118
119
  pool = @entries.values.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
@@ -296,6 +297,10 @@ module PWN
296
297
  # )
297
298
 
298
299
  public_class_method def self.discover(opts = {})
300
+ eager_load!(opts)
301
+ end
302
+
303
+ public_class_method def self.eager_load!(opts = {})
299
304
  force = opts[:force] ||= false
300
305
  return @entries.keys if @discovered && !force
301
306
 
@@ -311,6 +316,19 @@ module PWN
311
316
  @entries.keys
312
317
  end
313
318
 
319
+ public_class_method def self.selftest(opts = {})
320
+ _dry = opts[:dry_run]
321
+ eager_load!
322
+ tools_dir = File.join(__dir__, 'tools')
323
+ files = Dir[File.join(tools_dir, '*.rb')]
324
+ rows = @entries.map do |name, e|
325
+ schema = e.schema.is_a?(Hash)
326
+ required = Array(e.schema&.dig(:parameters, :required) || e.schema&.dig('parameters', 'required'))
327
+ { name: name, schema_valid: schema, handler: !e.handler.nil?, required: required }
328
+ end
329
+ { tools: rows, files: files.length, registered: @entries.length, ok: rows.length >= files.length }
330
+ end
331
+
314
332
  private_class_method def self.safe_check(opts = {})
315
333
  entry = opts[:entry]
316
334
  entry.check.call
@@ -428,6 +446,16 @@ module PWN
428
446
  force: 'optional - re-require tool files even if already discovered (default false)'
429
447
  )
430
448
 
449
+ # Eager-load every tools/*.rb so Registry.definitions works in a cold REPL.
450
+ #{self}.eager_load!(
451
+ force: 'optional - re-require tool files even if already discovered (default false)'
452
+ )
453
+
454
+ # Schema/handler contract check for every registered tool.
455
+ #{self}.selftest(
456
+ dry_run: 'optional - reserved dry-run flag'
457
+ )
458
+
431
459
  # Print the AUTHOR(S) string for this module.
432
460
  #{self}.authors
433
461
  "
@@ -177,22 +177,37 @@ module PWN
177
177
  v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
178
178
  end
179
179
 
180
- v[:success] = promote_to_success?(
181
- orm: v[:source].to_s != 'heuristic' && v[:score].to_f >= 0.6,
182
- verify: if ground.nil?
183
- nil
184
- else
185
- ground[:verdict] == :confirmed
186
- end,
187
- critic: opts.key?(:critic_pass) ? opts[:critic_pass] : nil
188
- )
180
+ pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
181
+ v[:judge_score] = v[:score].to_f
182
+ vv = opts[:verifier_verdict]
183
+ vv = :pass if vv.nil? && (opts[:verifier_pass] == true || pass || (ground && ground[:verdict] == :confirmed))
184
+ vv = vv.to_s.to_sym if vv
185
+ v[:verifier_verdict] = vv
186
+ v[:verdict_class] = taxonomy_class(opts.merge(score: v[:score], verifier_verdict: vv, request: request, final: final))
187
+ v[:remediation_hint] = taxonomy_hint(verdict_class: v[:verdict_class])
188
+ prec = verifier_precedence?
189
+ if prec && vv == :pass
190
+ v[:success] = true
191
+ else
192
+ v[:success] = promote_to_success?(
193
+ orm: v[:source].to_s != 'heuristic' && v[:score].to_f >= 0.6,
194
+ verify: if ground.nil?
195
+ nil
196
+ else
197
+ ground[:verdict] == :confirmed
198
+ end,
199
+ critic: opts.key?(:critic_pass) ? opts[:critic_pass] : nil
200
+ )
201
+ end
189
202
  v[:needs_spot_check] = v[:success] && v[:score].to_f >= 0.85 && (rand < 0.05)
190
203
  v[:engine] = eng
191
- pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
192
204
  v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
193
205
  if pass
194
- v[:score] = [v[:score].to_f, 0.7].max
195
- v[:score] = [v[:score].to_f, 0.70].min
206
+ score = v[:score].to_f
207
+ v[:score] = [score, 0.6].max
208
+ v[:success] = true if prec
209
+ v[:verifier_verdict] ||= :pass
210
+ v[:verdict] = :solved
196
211
  end
197
212
  v[:score_components] ||= {
198
213
  judge: v[:score].to_f,
@@ -201,7 +216,16 @@ module PWN
201
216
  weights: { overlap: pass ? 0.0 : 0.15 }
202
217
  }
203
218
  v[:score_components][:weights][:overlap] = 0.0 if pass
204
- Learning.note_outcome(task: request[0, 80], success: v[:score].to_f >= 0.6, score: v[:score], details: v[:score_components].to_json) if commit && defined?(Learning) && opts[:persist_components]
219
+ if commit && defined?(Learning) && opts[:persist_components]
220
+ Learning.note_outcome(
221
+ task: request[0, 80],
222
+ success: v[:success],
223
+ score: v[:score],
224
+ details: v[:score_components].to_json,
225
+ verifier_verdict: v[:verifier_verdict],
226
+ verdict_class: v[:verdict_class]
227
+ )
228
+ end
205
229
  # W3 — write Brier on every judged turn so overconfidence can
206
230
  # throttle max_iters/critic even when plan_first never fired.
207
231
  if commit
@@ -1076,7 +1100,7 @@ module PWN
1076
1100
  CHEAP_ORM_TEMP = 0.1
1077
1101
  CHEAP_ORM_TRACE_N = 12
1078
1102
  ORM_SAMPLE_WEIGHT = 1.0
1079
- HEURISTIC_SAMPLE_WEIGHT = 0.25
1103
+ HEURISTIC_SAMPLE_WEIGHT = 0.45
1080
1104
  ERROR_SAMPLE_WEIGHT = 0.15
1081
1105
  ENGINE_CHAT_MODS = {
1082
1106
  openai: 'PWN::AI::OpenAI',
@@ -1254,6 +1278,45 @@ module PWN
1254
1278
  false
1255
1279
  end
1256
1280
 
1281
+ private_class_method def self.verifier_precedence?(opts = {})
1282
+ return true unless opts.is_a?(Hash)
1283
+ return true unless defined?(PWN::Env)
1284
+
1285
+ v = PWN::Env.dig(:ai, :reward, :verifier_precedence)
1286
+ v != false
1287
+ rescue StandardError
1288
+ true
1289
+ end
1290
+
1291
+ private_class_method def self.taxonomy_class(opts = {})
1292
+ given = (opts[:verdict_class] || opts['verdict_class']).to_s
1293
+ return given unless given.empty?
1294
+
1295
+ vv = opts[:verifier_verdict]
1296
+ score = opts[:score].to_f
1297
+ final = opts[:final].to_s
1298
+ request = opts[:request].to_s
1299
+ return 'unverified_claim' if vv == :pass && score < 0.6
1300
+ return 'missing_artifact' if request.match?(%r{/(?:tmp|home|opt|var)/}) && !final.match?(%r{/(?:tmp|home|opt|var)/})
1301
+ return 'wrong_path' if request.include?('/tmp/') && final.include?('/tmp/') && request.split.none? { |tok| tok.start_with?('/') && final.include?(tok) }
1302
+ return 'scope_miss' if final.match?(/out of scope|SCOPE_DENY/i)
1303
+ return 'partial_coverage' if score.between?(0.3, 0.59)
1304
+ return 'style_only' if score >= 0.6 && Array(opts[:trace]).empty?
1305
+
1306
+ 'unverified_claim'
1307
+ end
1308
+
1309
+ private_class_method def self.taxonomy_hint(opts = {})
1310
+ {
1311
+ 'missing_artifact' => 'Write the requested path then read it back.',
1312
+ 'wrong_path' => 'Use the absolute path named in the original request.',
1313
+ 'unverified_claim' => 'Add a verifier PASS (file readback, exit 0, or hash match).',
1314
+ 'scope_miss' => 'Stay inside the active engagement scope.',
1315
+ 'partial_coverage' => 'Finish remaining request clauses before claiming done.',
1316
+ 'style_only' => 'Produce a host-visible artifact, not prose restyling.'
1317
+ }[opts[:verdict_class].to_s] || 'Produce evidence that matches the original request.'
1318
+ end
1319
+
1257
1320
  private_class_method def self.cheap_orm_timeout
1258
1321
  n = agent_flag(key: :reward_llm_timeout, default: CHEAP_ORM_TIMEOUT).to_i
1259
1322
  n = CHEAP_ORM_TIMEOUT if n < 2
@@ -1770,7 +1833,9 @@ module PWN
1770
1833
  critic_pass: 'optional - critic pass value consumed by #judge',
1771
1834
  predicted: 'optional - predicted value consumed by #judge',
1772
1835
  proxy_ok: 'optional - proxy ok value consumed by #judge',
1773
- persist_components: 'optional - write score_components into the learning ledger'
1836
+ persist_components: 'optional - write score_components into the learning ledger',
1837
+ verifier_verdict: 'optional - :pass when a deterministic verifier already succeeded',
1838
+ verifier_pass: 'optional - true as a boolean alias for verifier_verdict :pass'
1774
1839
  )
1775
1840
 
1776
1841
  # Run promote to success and return its result
@@ -438,6 +438,34 @@ module PWN
438
438
  "FACTS #{rows.map { |r| "#{r[:kind]}=#{r[:value]}" }.join(' ')}"
439
439
  end
440
440
 
441
+ public_class_method def self.claim(opts = {})
442
+ eng = (opts[:engagement_id] || 'default').to_s
443
+ unit = opts[:unit].to_s
444
+ raise 'ERROR: unit is required' if unit.empty?
445
+
446
+ ttl = (opts[:ttl] || 300).to_i
447
+ agent = (opts[:agent_id] || 'anon').to_s
448
+ dir = File.join(Dir.home, '.pwn', 'swarm', eng)
449
+ FileUtils.mkdir_p(dir)
450
+ path = File.join(dir, "#{unit.gsub(/[^A-Za-z0-9._:-]/, '_')}.claim")
451
+ now = Time.now.to_i
452
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
453
+ f.flock(File::LOCK_EX)
454
+ existing = begin
455
+ JSON.parse(f.read, symbolize_names: true)
456
+ rescue StandardError
457
+ {}
458
+ end
459
+ return { ok: false, unit: unit, holder: existing[:agent_id] } if existing[:until].to_i > now && existing[:agent_id].to_s != agent
460
+
461
+ row = { unit: unit, agent_id: agent, until: now + ttl }
462
+ f.rewind
463
+ f.truncate(0)
464
+ f.write(JSON.generate(row))
465
+ { ok: true, unit: unit, agent_id: agent, until: row[:until] }
466
+ end
467
+ end
468
+
441
469
  # Author(s):: 0day Inc. <support@0dayinc.com>
442
470
 
443
471
  public_class_method def self.authors
@@ -535,6 +563,14 @@ module PWN
535
563
  engagement_id: 'optional - engagement id (defaults to default)'
536
564
  )
537
565
 
566
+ # Atomically claim a work unit (INSERT-or-fail until TTL).
567
+ #{self}.claim(
568
+ unit: 'required - normalized target+phase key',
569
+ engagement_id: 'optional - engagement id (defaults to default)',
570
+ ttl: 'optional - seconds until the claim expires (defaults to 300)',
571
+ agent_id: 'optional - claimant id'
572
+ )
573
+
538
574
  # Print the AUTHOR(S) string for this module.
539
575
  #{self}.authors
540
576
  "
@@ -5,6 +5,9 @@ require 'json'
5
5
  require 'fileutils'
6
6
  require 'securerandom'
7
7
  require 'time'
8
+ require 'openssl'
9
+ require 'uri'
10
+ require 'base64'
8
11
 
9
12
  module PWN
10
13
  module AI
@@ -41,7 +44,12 @@ module PWN
41
44
  end
42
45
 
43
46
  public_class_method def self.placeholder?(opts = {})
44
- PLACEHOLDER_RX.match?(opts[:text].to_s)
47
+ s = opts[:text].to_s.dup
48
+ s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m, ' ')
49
+ s.gsub!(/<<[-~]?\s*(\w+).*?^\1\s*$/m, ' ')
50
+ s.gsub!(/'[^']*'/, "''")
51
+ s.gsub!(/"([^"\\]|\\.)*"/, '""')
52
+ PLACEHOLDER_RX.match?(s)
45
53
  rescue StandardError
46
54
  false
47
55
  end
@@ -60,18 +68,36 @@ module PWN
60
68
  end
61
69
 
62
70
  public_class_method def self.mint_canary(opts = {})
63
- n = (opts[:bytes] || 8).to_i
64
- n = 8 if n <= 0
65
- tok = "PWNCANARY#{SecureRandom.hex(n)}"
71
+ _bytes = opts[:bytes]
72
+ turn = (opts[:turn] || Thread.current[:pwn_loop_iter] || 0).to_i
73
+ sid = (opts[:session_id] || Thread.current[:pwn_session_id] || 'sess').to_s
74
+ key = (Thread.current[:pwn_canary_key] ||= SecureRandom.hex(16))
75
+ tok = OpenSSL::HMAC.hexdigest('SHA256', key, "#{sid}:#{turn}")[0, 16]
76
+ hist = Thread.current[:pwn_canary_hist] ||= []
77
+ hist << tok
78
+ hist.shift while hist.length > 4
66
79
  Thread.current[:pwn_canary] = tok
67
80
  tok
68
81
  end
69
82
 
70
83
  public_class_method def self.canary_leak?(opts = {})
71
- tok = Thread.current[:pwn_canary].to_s
72
- return false if tok.empty?
73
-
74
- opts[:text].to_s.include?(tok)
84
+ text = opts[:text].to_s
85
+ toks = Array(Thread.current[:pwn_canary_hist])
86
+ toks << Thread.current[:pwn_canary].to_s
87
+ toks = toks.reject(&:empty?).uniq
88
+ return false if toks.empty?
89
+
90
+ require 'base64'
91
+ toks.any? do |tok|
92
+ next true if text.include?(tok)
93
+
94
+ b64 = Base64.strict_encode64(tok)
95
+ b64u = Base64.urlsafe_encode64(tok)
96
+ hex = tok.each_byte.map { |b| format('%02x', b) }.join
97
+ enc = URI.encode_www_form_component(tok)
98
+ rot = tok.tr('A-Za-z', 'N-ZA-Mn-za-m')
99
+ text.include?(b64) || text.include?(b64u) || text.include?(hex) || text.include?(enc) || text.include?(rot)
100
+ end
75
101
  end
76
102
 
77
103
  public_class_method def self.injection_score(opts = {})
@@ -442,6 +468,49 @@ module PWN
442
468
  nil
443
469
  end
444
470
 
471
+ public_class_method def self.policy_decision(opts = {})
472
+ name = opts[:name].to_s
473
+ blob = opts[:args].inspect
474
+ path = File.join(Dir.home, '.pwn', 'toolguard.yaml')
475
+ return nil unless File.file?(path)
476
+
477
+ require 'yaml'
478
+ doc = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
479
+ Array(doc['rules'] || doc[:rules]).each do |rule|
480
+ next unless rule.is_a?(Hash)
481
+
482
+ tool = (rule['tool'] || rule[:tool]).to_s
483
+ next unless tool.empty? || tool == name
484
+
485
+ pat = (rule['arg_pattern'] || rule[:arg_pattern]).to_s
486
+ next if pat.empty? || blob.match?(Regexp.new(pat)) == false
487
+
488
+ action = (rule['action'] || rule[:action] || 'allow').to_s
489
+ next if action == 'allow'
490
+
491
+ return {
492
+ action: action,
493
+ success: false,
494
+ error: (rule['reason'] || rule[:reason] || 'toolguard policy deny').to_s,
495
+ code: 'POLICY_DENY',
496
+ tool: name
497
+ }
498
+ end
499
+ nil
500
+ rescue StandardError
501
+ nil
502
+ end
503
+
504
+ public_class_method def self.scope_check!(opts = {})
505
+ return nil unless defined?(Engagement)
506
+
507
+ Engagement.deny_if_out_of_scope(
508
+ args: opts[:args],
509
+ command: opts[:command] || opts[:args].inspect,
510
+ text: opts[:text]
511
+ )
512
+ end
513
+
445
514
  public_class_method def self.rfc1918?(opts = {})
446
515
  oct = opts[:ip].to_s.split('.').map(&:to_i)
447
516
  return false unless oct.length == 4
@@ -535,7 +604,9 @@ module PWN
535
604
 
536
605
  # Mint a per-thread session canary token stored on Thread.current.
537
606
  #{self}.mint_canary(
538
- bytes: 'optional - hex length (defaults to 8)'
607
+ bytes: 'optional - unused reserved length (HMAC uses 16 hex chars)',
608
+ turn: 'optional - turn number mixed into the HMAC',
609
+ session_id: 'optional - session id mixed into the HMAC'
539
610
  )
540
611
 
541
612
  # True when text contains the current session canary.
@@ -690,6 +761,19 @@ module PWN
690
761
  payload: 'optional - command string if class omitted'
691
762
  )
692
763
 
764
+ # Evaluate ~/.pwn/toolguard.yaml first-match rules.
765
+ #{self}.policy_decision(
766
+ name: 'required - tool name',
767
+ args: 'required - Hash of tool arguments'
768
+ )
769
+
770
+ # Block network args outside the active engagement scope.
771
+ #{self}.scope_check!(
772
+ args: 'optional - Hash of tool arguments',
773
+ command: 'optional - command string to scan for hosts',
774
+ text: 'optional - free-form blob to scan'
775
+ )
776
+
693
777
  # Print the AUTHOR(S) string for this module.
694
778
  #{self}.authors
695
779
  "
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'debug_session',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'debug_session',
10
+ description: 'Run a binary under gdb to crash and return typed crash_info.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ binary: { type: 'string' },
15
+ args: { type: 'array', items: { type: 'string' } },
16
+ script: { type: 'array', items: { type: 'string' } }
17
+ },
18
+ required: %w[binary]
19
+ }
20
+ },
21
+ handler: lambda { |args|
22
+ PWN::Plugins::GDB.debug_session(
23
+ binary: args[:binary] || args['binary'],
24
+ args: args[:args] || args['args'],
25
+ commands: args[:script] || args['script']
26
+ )
27
+ }
28
+ )
29
+ PWN::AI::Agent::Registry.register(
30
+ name: 'binary_diff',
31
+ toolset: 'pwn',
32
+ schema: {
33
+ name: 'binary_diff',
34
+ description: 'Diff two binaries (radiff2 -C) and rank changed functions.',
35
+ parameters: {
36
+ type: 'object',
37
+ properties: { a: { type: 'string' }, b: { type: 'string' } },
38
+ required: %w[a b]
39
+ }
40
+ },
41
+ handler: lambda { |args|
42
+ PWN::Plugins::BinaryParser.diff(a: args[:a] || args['a'], b: args[:b] || args['b'])
43
+ }
44
+ )
45
+ PWN::AI::Agent::Registry.register(
46
+ name: 'crash_triage',
47
+ toolset: 'pwn',
48
+ schema: {
49
+ name: 'crash_triage',
50
+ description: 'Dedupe AFL crashes, minimize, debug, and emit findings with PoCs.',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: { campaign: { type: 'string' }, out_dir: { type: 'string' }, target: { type: 'string' } }
54
+ }
55
+ },
56
+ handler: lambda { |args|
57
+ dir = args[:out_dir] || args['out_dir'] || File.join(Dir.home, '.pwn', 'fuzz', (args[:campaign] || args['campaign'] || 'default').to_s)
58
+ PWN::Plugins::AFLplusplus.crash_triage(out_dir: dir)
59
+ }
60
+ )
61
+ PWN::AI::Agent::Registry.register(
62
+ name: 'capability_request',
63
+ toolset: 'sessions',
64
+ schema: {
65
+ name: 'capability_request',
66
+ description: 'Request a grantable capability (never auto-sudo).',
67
+ parameters: {
68
+ type: 'object',
69
+ properties: { cap: { type: 'string' }, reason: { type: 'string' }, operator_ack: { type: 'boolean' } },
70
+ required: %w[cap]
71
+ }
72
+ },
73
+ handler: lambda { |args|
74
+ PWN::Plugins::Capability.request(
75
+ cap: args[:cap] || args['cap'],
76
+ reason: args[:reason] || args['reason'],
77
+ operator_ack: args[:operator_ack] || args['operator_ack']
78
+ )
79
+ }
80
+ )
81
+ PWN::AI::Agent::Registry.register(
82
+ name: 'emulate',
83
+ toolset: 'pwn',
84
+ schema: {
85
+ name: 'emulate',
86
+ description: 'Emulate one function (Unicorn) and return registers/memory writes.',
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ binary: { type: 'string' },
91
+ addr: { type: 'string' },
92
+ args: { type: 'array' },
93
+ max_insns: { type: 'integer' }
94
+ },
95
+ required: %w[binary addr]
96
+ }
97
+ },
98
+ handler: lambda { |args|
99
+ PWN::Plugins::Emulator.emulate(
100
+ binary: args[:binary] || args['binary'],
101
+ addr: args[:addr] || args['addr'],
102
+ args: args[:args] || args['args'],
103
+ max_insns: args[:max_insns] || args['max_insns']
104
+ )
105
+ }
106
+ )
107
+ PWN::AI::Agent::Registry.register(
108
+ name: 'detonate',
109
+ toolset: 'pwn',
110
+ schema: {
111
+ name: 'detonate',
112
+ description: 'Run a sample in an isolated container. Refuses if isolation is missing.',
113
+ parameters: {
114
+ type: 'object',
115
+ properties: { path: { type: 'string' }, timeout: { type: 'integer' }, network: { type: 'string' } },
116
+ required: %w[path]
117
+ }
118
+ },
119
+ handler: lambda { |args|
120
+ PWN::Plugins::Detonate.detonate(
121
+ path: args[:path] || args['path'],
122
+ timeout: args[:timeout] || args['timeout'],
123
+ network: args[:network] || args['network']
124
+ )
125
+ }
126
+ )
127
+ PWN::AI::Agent::Registry.register(
128
+ name: 'budget_status',
129
+ toolset: 'sessions',
130
+ schema: {
131
+ name: 'budget_status',
132
+ description: 'Read-only remaining time/token/mutation budget for this loop.',
133
+ parameters: { type: 'object', properties: {} }
134
+ },
135
+ handler: ->(_args) { PWN::AI::Agent::Loop.budget_status }
136
+ )
137
+ PWN::AI::Agent::Registry.register(
138
+ name: 'pty_expect',
139
+ toolset: 'terminal',
140
+ schema: {
141
+ name: 'pty_expect',
142
+ description: 'Send optional data and wait for a regex on a PTY with timeout.',
143
+ parameters: {
144
+ type: 'object',
145
+ properties: {
146
+ session: { type: 'string' },
147
+ id: { type: 'string' },
148
+ send: { type: 'string' },
149
+ expect: { type: 'string' },
150
+ timeout: { type: 'integer' }
151
+ }
152
+ }
153
+ },
154
+ handler: lambda { |args|
155
+ id = args[:session] || args[:id] || args['session'] || args['id']
156
+ line = args[:send] || args['send']
157
+ PWN::Plugins::ProcessTube.write_line(id: id, line: line) if line.to_s != ''
158
+ PWN::Plugins::ProcessTube.expect(id: id, until: args[:expect] || args['expect'], timeout: args[:timeout])
159
+ }
160
+ )
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'engagement_open',
7
+ toolset: 'sessions',
8
+ schema: {
9
+ name: 'engagement_open',
10
+ description: 'Open or update an engagement scope document and mark it active.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ name: { type: 'string' },
15
+ scope_cidrs: { type: 'array', items: { type: 'string' } },
16
+ scope_domains: { type: 'array', items: { type: 'string' } }
17
+ },
18
+ required: %w[name]
19
+ }
20
+ },
21
+ handler: lambda { |args|
22
+ PWN::AI::Agent::Engagement.open(
23
+ name: args[:name] || args['name'],
24
+ scope_cidrs: args[:scope_cidrs] || args['scope_cidrs'],
25
+ scope_domains: args[:scope_domains] || args['scope_domains']
26
+ )
27
+ }
28
+ )
29
+ PWN::AI::Agent::Registry.register(
30
+ name: 'engagement_close',
31
+ toolset: 'sessions',
32
+ schema: { name: 'engagement_close', description: 'Clear the active engagement.', parameters: { type: 'object', properties: {} } },
33
+ handler: ->(_args) { PWN::AI::Agent::Engagement.close }
34
+ )
35
+ PWN::AI::Agent::Registry.register(
36
+ name: 'engagement_status',
37
+ toolset: 'sessions',
38
+ schema: { name: 'engagement_status', description: 'Return the active engagement document.', parameters: { type: 'object', properties: { name: { type: 'string' } } } },
39
+ handler: ->(args) { PWN::AI::Agent::Engagement.status(name: args[:name] || args['name']) }
40
+ )