pwn 0.5.721 → 0.5.723

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/bin/pwn_setup +5 -5
  4. data/documentation/AI-Integration.md +34 -1
  5. data/documentation/Policy-Benchmark.md +151 -0
  6. data/documentation/Reinforcement-Learning.md +27 -1
  7. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
  10. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
  13. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
  14. data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
  17. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
  18. data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
  19. data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
  21. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  24. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
  25. data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
  26. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  29. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
  31. data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
  32. data/lib/pwn/ai/agent/curriculum.rb +37 -45
  33. data/lib/pwn/ai/agent/dispatch.rb +9 -0
  34. data/lib/pwn/ai/agent/engagement.rb +59 -0
  35. data/lib/pwn/ai/agent/learning.rb +111 -44
  36. data/lib/pwn/ai/agent/loop.rb +50 -8
  37. data/lib/pwn/ai/agent/metrics.rb +70 -2
  38. data/lib/pwn/ai/agent/mistakes.rb +67 -1
  39. data/lib/pwn/ai/agent/policy.rb +142 -16
  40. data/lib/pwn/ai/agent/registry.rb +1 -1
  41. data/lib/pwn/ai/agent/reward.rb +153 -53
  42. data/lib/pwn/ai/agent/swarm.rb +235 -35
  43. data/lib/pwn/ai/agent/tool_guard.rb +13 -1
  44. data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
  45. data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
  46. data/lib/pwn/ai/agent/tools/context.rb +38 -0
  47. data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
  48. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
  49. data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
  50. data/lib/pwn/ai/agent/tools/learning.rb +5 -6
  51. data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
  52. data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
  53. data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
  54. data/lib/pwn/ai/agent/tools/shell.rb +10 -1
  55. data/lib/pwn/ai/agent/tools/skills.rb +30 -0
  56. data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
  57. data/lib/pwn/ai/context.rb +193 -0
  58. data/lib/pwn/ai/http_retry.rb +53 -7
  59. data/lib/pwn/ai/open_ai.rb +302 -45
  60. data/lib/pwn/ai.rb +1 -0
  61. data/lib/pwn/migrate.rb +10 -1
  62. data/lib/pwn/plugins/artifact_registry.rb +40 -17
  63. data/lib/pwn/plugins/binary_parser.rb +4 -1
  64. data/lib/pwn/plugins/exploit_dev.rb +15 -0
  65. data/lib/pwn/plugins/findings.rb +48 -8
  66. data/lib/pwn/plugins/gdb.rb +17 -0
  67. data/lib/pwn/plugins/gdbmi.rb +128 -0
  68. data/lib/pwn/plugins/ghidra_headless.rb +104 -0
  69. data/lib/pwn/plugins/jobs.rb +72 -0
  70. data/lib/pwn/plugins/packet.rb +51 -0
  71. data/lib/pwn/plugins/preflight_checker.rb +29 -0
  72. data/lib/pwn/plugins/process_tube.rb +24 -7
  73. data/lib/pwn/plugins/radare2.rb +14 -2
  74. data/lib/pwn/plugins/repl.rb +6 -1
  75. data/lib/pwn/plugins/transparent_browser.rb +64 -0
  76. data/lib/pwn/plugins.rb +2 -0
  77. data/lib/pwn/reports/engagement.rb +19 -0
  78. data/lib/pwn/sessions.rb +3 -1
  79. data/lib/pwn/version.rb +1 -1
  80. data/scripts/benchmark_policy.rb +276 -0
  81. data/spec/documentation/installation_md_spec.rb +18 -4
  82. data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
  83. data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
  84. data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
  85. data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
  86. data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
  87. data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
  88. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
  89. data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
  90. data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
  91. data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
  92. data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
  93. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
  94. data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
  95. data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
  96. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
  97. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
  98. data/spec/lib/pwn/ai/context_spec.rb +48 -0
  99. data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
  100. data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
  101. data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
  102. data/spec/lib/pwn/migrate_spec.rb +24 -0
  103. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
  104. data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
  105. data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
  106. data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
  107. data/third_party/pwn_rdoc.jsonl +86 -2
  108. metadata +24 -5
@@ -6,6 +6,7 @@ require 'time'
6
6
  require 'fileutils'
7
7
  require 'securerandom'
8
8
  require 'socket'
9
+ require 'digest'
9
10
 
10
11
  module PWN
11
12
  module AI
@@ -41,19 +42,14 @@ module PWN
41
42
  # Supported Method Parameters::
42
43
  # personas = PWN::AI::Agent::Swarm.personas
43
44
 
44
- public_class_method def self.personas
45
- return {} unless File.exist?(AGENTS_FILE)
46
-
47
- raw = YAML.safe_load_file(
48
- AGENTS_FILE,
49
- permitted_classes: [Symbol],
50
- aliases: true,
51
- symbolize_names: true
52
- ) || {}
53
- raw.transform_values { |v| normalize_persona(persona: v) }
54
- rescue StandardError => e
55
- warn "[pwn-ai/swarm] failed to load #{AGENTS_FILE}: #{e.class}: #{e.message}"
56
- {}
45
+ public_class_method def self.personas(opts = {})
46
+ all = load_personas_file(path: AGENTS_FILE)
47
+ sid = opts[:swarm_id].to_s
48
+ unless sid.empty?
49
+ local = load_personas_file(path: File.join(SWARM_ROOT, sid, 'agents.yml'))
50
+ all = all.merge(local)
51
+ end
52
+ all
57
53
  end
58
54
 
59
55
  # Supported Method Parameters::
@@ -70,11 +66,21 @@ module PWN
70
66
  raise ArgumentError, 'name is required' if name.strip.empty?
71
67
  raise ArgumentError, 'role is required' if opts[:role].to_s.strip.empty?
72
68
 
73
- all = personas
74
- all[name.to_sym] = normalize_persona(persona: opts)
75
- FileUtils.mkdir_p(File.dirname(AGENTS_FILE))
76
- File.write(AGENTS_FILE, YAML.dump(deep_stringify(hash: all)))
77
- { name: name, persona: all[name.to_sym], file: AGENTS_FILE }
69
+ packed = pack_specialist(opts)
70
+ sid = opts[:swarm_id].to_s
71
+ ephemeral = opts[:ephemeral] == true || (!sid.empty? && opts[:global] != true)
72
+ path = if ephemeral && !sid.empty?
73
+ FileUtils.mkdir_p(File.join(SWARM_ROOT, sid))
74
+ File.join(SWARM_ROOT, sid, 'agents.yml')
75
+ else
76
+ AGENTS_FILE
77
+ end
78
+ all = load_personas_file(path: path)
79
+ row = normalize_persona(persona: opts.merge(toolsets: packed[:toolsets], skills: packed[:skills]))
80
+ all[name.to_sym] = row
81
+ FileUtils.mkdir_p(File.dirname(path))
82
+ File.write(path, YAML.dump(deep_stringify(hash: all)))
83
+ { name: name, persona: row, file: path, ephemeral: ephemeral }
78
84
  end
79
85
 
80
86
  # Supported Method Parameters::
@@ -131,13 +137,27 @@ module PWN
131
137
  raise ArgumentError, 'swarm_id is required' if sid.empty?
132
138
 
133
139
  FileUtils.mkdir_p(File.join(SWARM_ROOT, sid))
140
+ raw = opts[:content].to_s
134
141
  entry = {
135
142
  ts: Time.now.utc.iso8601,
136
143
  from: opts[:from].to_s,
137
144
  to: (opts[:to] || :all).to_s,
138
- content: opts[:content].to_s
145
+ content: raw
139
146
  }
140
- File.open(bus_path(swarm_id: sid), 'a') { |f| f.puts(JSON.generate(entry)) }
147
+ if raw.bytesize > 400
148
+ stored = if defined?(PWN::Plugins::ArtifactRegistry)
149
+ PWN::Plugins::ArtifactRegistry.put(bytes: raw, kind: 'swarm-bus', session_id: sid)
150
+ else
151
+ { sha256: Digest::SHA256.hexdigest(raw), path: bus_path(swarm_id: sid) }
152
+ end
153
+ entry[:content] = raw.byteslice(0, 400)
154
+ entry[:ref] = stored[:path]
155
+ entry[:sha256] = stored[:sha256]
156
+ end
157
+ File.open(bus_path(swarm_id: sid), 'a') do |f|
158
+ f.flock(File::LOCK_EX)
159
+ f.puts(JSON.generate(entry))
160
+ end
141
161
  entry
142
162
  end
143
163
 
@@ -170,11 +190,16 @@ module PWN
170
190
 
171
191
  public_class_method def self.ask(opts = {})
172
192
  name = opts[:name].to_s
173
- persona = personas[name.to_sym]
193
+ depth = Thread.current[:pwn_swarm_depth] || 0
194
+ sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
195
+ persona = personas(swarm_id: sid)[name.to_sym]
174
196
  raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
175
197
 
176
- sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
177
- depth = Thread.current[:pwn_swarm_depth] || 0
198
+ if opts[:unit].to_s != ''
199
+ held = claim(unit: opts[:unit], agent_id: name, ttl: opts[:ttl] || 300, engagement_id: opts[:engagement_id] || sid)
200
+ return { ok: false, error: 'claim_held', claim: held } unless held[:ok]
201
+ end
202
+
178
203
  if depth >= max_depth
179
204
  raise "swarm recursion depth #{depth} >= max_depth #{max_depth} " \
180
205
  '(PWN::Env[:ai][:agent][:max_depth])'
@@ -189,6 +214,7 @@ module PWN
189
214
 
190
215
  empty_tools = opts[:text_only] == true || Array(persona[:toolsets]).empty?
191
216
  Thread.current[:pwn_swarm_depth] = depth + 1
217
+ Thread.current[:pwn_swarm_id] = sid
192
218
  reply = with_persona_env(persona: persona) do
193
219
  Loop.run(
194
220
  request: opts[:request].to_s,
@@ -196,12 +222,16 @@ module PWN
196
222
  enabled_toolsets: empty_tools ? [] : persona[:toolsets],
197
223
  core_only: empty_tools ? false : true,
198
224
  system_role_content: sys,
199
- on_tool: opts[:on_tool]
225
+ on_tool: opts[:on_tool],
226
+ nested: true
200
227
  )
201
228
  end
202
229
 
203
230
  bus_append(swarm_id: sid, from: name, to: opts[:to] || :all, content: reply)
204
- { swarm_id: sid, name: name, session_id: session_id, reply: reply }
231
+ inbox = child_inbox(session_id: session_id, name: name)
232
+ honesty = child_honesty(name: name, toolsets: persona[:toolsets], session_id: session_id, skills: persona[:skills])
233
+ (Thread.current[:pwn_swarm_honesty] ||= []) << honesty.merge(name: name)
234
+ { ok: true, swarm_id: sid, name: name, session_id: session_id, reply: reply, inbox: inbox, honesty: honesty }
205
235
  ensure
206
236
  Thread.current[:pwn_swarm_depth] = depth
207
237
  end
@@ -263,10 +293,10 @@ module PWN
263
293
  names = personas.keys.map(&:to_s) if names.empty?
264
294
  sid = opts[:swarm_id] || create(topic: req[0, 60])[:swarm_id]
265
295
 
266
- replies = names.to_h do |n|
267
- [n, ask(name: n, request: req, swarm_id: sid,
268
- from: 'broadcast', on_tool: opts[:on_tool])[:reply]]
296
+ threads = names.map do |n|
297
+ [n, Thread.new { ask(name: n, request: req, swarm_id: sid, from: 'broadcast', on_tool: opts[:on_tool])[:reply] }]
269
298
  end
299
+ replies = threads.to_h { |n, th| [n, th.value] }
270
300
  { swarm_id: sid, replies: replies }
271
301
  end
272
302
 
@@ -278,6 +308,23 @@ module PWN
278
308
  File.join(SWARM_ROOT, opts[:swarm_id].to_s, 'bus.jsonl')
279
309
  end
280
310
 
311
+ private_class_method def self.load_personas_file(opts = {})
312
+ path = opts[:path].to_s
313
+ return {} unless File.file?(path)
314
+
315
+ raw = YAML.safe_load_file(
316
+ path,
317
+ permitted_classes: [Symbol],
318
+ aliases: true,
319
+ symbolize_names: true
320
+ ) || {}
321
+ raw = {} unless raw.is_a?(Hash)
322
+ raw.transform_values { |v| normalize_persona(persona: v) }
323
+ rescue StandardError => e
324
+ warn "[pwn-ai/swarm] failed to load #{path}: #{e.class}: #{e.message}"
325
+ {}
326
+ end
327
+
281
328
  private_class_method def self.persona_session(opts = {})
282
329
  sid = opts[:swarm_id].to_s
283
330
  name = opts[:name].to_s
@@ -337,10 +384,12 @@ module PWN
337
384
  prev_active = ai[:active]
338
385
  prev_iters = agent_h[:max_iters]
339
386
 
340
- ai[:active] = persona[:engine].to_s if persona[:engine]
341
- agent_h[:max_iters] = persona[:max_iters] if persona[:max_iters]
387
+ ai[:active] = persona[:engine].to_s if persona[:engine]
388
+ Thread.current[:pwn_swarm_engine] = persona[:engine].to_s if persona[:engine]
389
+ agent_h[:max_iters] = persona[:max_iters] if persona[:max_iters]
342
390
  yield
343
391
  ensure
392
+ Thread.current[:pwn_swarm_engine] = nil
344
393
  if ai
345
394
  ai[:active] = prev_active
346
395
  agent_h[:max_iters] = prev_iters
@@ -352,7 +401,11 @@ module PWN
352
401
  {
353
402
  role: p[:role].to_s,
354
403
  engine: (p[:engine].to_s.empty? ? nil : p[:engine].to_s.downcase.to_sym),
355
- toolsets: Array(p[:toolsets]).map(&:to_s).then { |a| a.empty? ? DEFAULT_TOOLSET.dup : a },
404
+ toolsets: begin
405
+ raw_ts = p[:toolsets]
406
+ raw_ts.nil? ? DEFAULT_TOOLSET.dup : Array(raw_ts).map(&:to_s)
407
+ end,
408
+ skills: Array(p[:skills]).map(&:to_s).first(3),
356
409
  max_iters: (p[:max_iters] || DEFAULT_ITERS).to_i
357
410
  }
358
411
  end
@@ -392,6 +445,10 @@ module PWN
392
445
  hosts = opts[:targets].to_s.split(/[,\s]+/).reject(&:empty?)
393
446
  ports = opts[:ports].to_s.split(/[,\s]+/).map(&:to_i).reject(&:zero?)
394
447
  ports = [80, 443] if ports.empty?
448
+ if defined?(PWN::Plugins::Packet) && PWN::Plugins::Packet.respond_to?(:tcp_connect_scan)
449
+ row = PWN::Plugins::Packet.tcp_connect_scan(hosts: hosts, ports: ports)
450
+ return Array(row[:results])
451
+ end
395
452
 
396
453
  hosts.product(ports).map do |host, port|
397
454
  Thread.new do
@@ -445,7 +502,7 @@ module PWN
445
502
 
446
503
  ttl = (opts[:ttl] || 300).to_i
447
504
  agent = (opts[:agent_id] || 'anon').to_s
448
- dir = File.join(Dir.home, '.pwn', 'swarm', eng)
505
+ dir = File.join(SWARM_ROOT, eng)
449
506
  FileUtils.mkdir_p(dir)
450
507
  path = File.join(dir, "#{unit.gsub(/[^A-Za-z0-9._:-]/, '_')}.claim")
451
508
  now = Time.now.to_i
@@ -466,6 +523,102 @@ module PWN
466
523
  end
467
524
  end
468
525
 
526
+ public_class_method def self.pack_specialist(opts = {})
527
+ name = opts[:name].to_s
528
+ skills = Array(opts[:skills]).map(&:to_s).reject(&:empty?).first(3)
529
+ toolsets = Array(opts[:toolsets]).map(&:to_s)
530
+ toolsets -= %w[swarm] unless opts[:orchestrator]
531
+ toolsets = DEFAULT_TOOLSET.dup if toolsets.empty?
532
+ { name: name, skills: skills, toolsets: toolsets }
533
+ end
534
+
535
+ public_class_method def self.child_inbox(opts = {})
536
+ sid = opts[:session_id].to_s
537
+ findings = if defined?(PWN::Plugins::Findings)
538
+ Array(PWN::Plugins::Findings.report).select { |r| r[:session_id].to_s == sid || sid.empty? }
539
+ else
540
+ []
541
+ end
542
+ arts = if defined?(PWN::Plugins::ArtifactRegistry)
543
+ Array(PWN::Plugins::ArtifactRegistry.list(session_id: sid))
544
+ else
545
+ []
546
+ end
547
+ {
548
+ name: opts[:name].to_s,
549
+ finding_ids: findings.map { |r| r[:id].to_s },
550
+ artifact_shas: arts.filter_map { |a| a[:sha256] || a['sha256'] },
551
+ coverage: []
552
+ }
553
+ end
554
+
555
+ public_class_method def self.child_honesty(opts = {})
556
+ inbox = child_inbox(opts)
557
+ toolsets = Array(opts[:toolsets]).map(&:to_s)
558
+ pwnish = toolsets.include?('pwn') || toolsets.include?('extrospection')
559
+ empty = Array(inbox[:finding_ids]).empty? && Array(inbox[:artifact_shas]).empty?
560
+ gap = pwnish && empty ? 'child_filed_nothing' : nil
561
+ { name: opts[:name].to_s, gap: gap, inbox: inbox }
562
+ end
563
+
564
+ public_class_method def self.honesty_unmet(opts = {})
565
+ _sid = opts[:swarm_id]
566
+ Array(Thread.current[:pwn_swarm_honesty]).filter_map do |h|
567
+ next unless h[:gap]
568
+
569
+ "child_filed_nothing:#{h[:name]}"
570
+ end
571
+ end
572
+
573
+ public_class_method def self.view_graph(opts = {})
574
+ sid = opts[:swarm_id].to_s
575
+ raise ArgumentError, 'swarm_id is required' if sid.empty?
576
+
577
+ map_path = File.join(SWARM_ROOT, sid, 'personas.json')
578
+ map = File.file?(map_path) ? JSON.parse(File.read(map_path)) : {}
579
+ agents = map.map { |name, sess| { name: name, session_id: sess } }
580
+ claims = Dir[File.join(SWARM_ROOT, sid, '*.claim')].filter_map do |path|
581
+ JSON.parse(File.read(path), symbolize_names: true)
582
+ rescue StandardError
583
+ nil
584
+ end
585
+ { swarm_id: sid, agents: agents, claims: claims }
586
+ end
587
+
588
+ public_class_method def self.migrate_personas(opts = {})
589
+ path = opts[:path].to_s
590
+ path = AGENTS_FILE if path.empty?
591
+ return { changed: false, path: path, patched: [] } unless File.file?(path)
592
+
593
+ raw = YAML.safe_load_file(
594
+ path,
595
+ permitted_classes: [Symbol],
596
+ aliases: true,
597
+ symbolize_names: true
598
+ )
599
+ return { changed: false, path: path, patched: [] } unless raw.is_a?(Hash)
600
+
601
+ patched = []
602
+ if raw[:escalator].is_a?(Hash)
603
+ ts = Array(raw[:escalator][:toolsets]).map(&:to_s)
604
+ if ts.sort == %w[memory pwn terminal]
605
+ raw[:escalator][:toolsets] = []
606
+ patched << 'escalator'
607
+ end
608
+ end
609
+ if raw[:scribe].is_a?(Hash)
610
+ ts = Array(raw[:scribe][:toolsets]).map(&:to_s)
611
+ unless ts.include?('pwn')
612
+ raw[:scribe][:toolsets] = ts + %w[pwn]
613
+ patched << 'scribe'
614
+ end
615
+ end
616
+ return { changed: false, path: path, patched: [] } if patched.empty?
617
+
618
+ File.write(path, YAML.dump(deep_stringify(hash: raw)))
619
+ { changed: true, path: path, patched: patched }
620
+ end
621
+
469
622
  # Author(s):: 0day Inc. <support@0dayinc.com>
470
623
 
471
624
  public_class_method def self.authors
@@ -477,7 +630,9 @@ module PWN
477
630
  public_class_method def self.help
478
631
  puts "USAGE:
479
632
  # Persona registry (~/.pwn/agents.yml)
480
- #{self}.personas
633
+ #{self}.personas(
634
+ swarm_id: 'optional - merge ephemeral personas from this swarm'
635
+ )
481
636
 
482
637
  # Run spawn and return its result
483
638
  #{self}.spawn(
@@ -485,7 +640,12 @@ module PWN
485
640
  role: 'required - system_role_content overlay for this persona',
486
641
  toolsets: 'optional - Array of Registry toolset names',
487
642
  engine: 'optional - :openai / :anthropic / :grok / :gemini / :ollama / :openwebui',
488
- max_iters: 'optional - per-turn iteration cap for this persona'
643
+ max_iters: 'optional - per-turn iteration cap for this persona',
644
+ skills: 'optional - Array of SOP skill names (capped at 3)',
645
+ swarm_id: 'optional - write ephemeral persona under this swarm',
646
+ ephemeral: 'optional - true to keep the persona off the host agents.yml',
647
+ global: 'optional - true to write ~/.pwn/agents.yml even with swarm_id',
648
+ orchestrator: 'optional - true to keep the swarm toolset'
489
649
  )
490
650
 
491
651
  # Run retire and return its result
@@ -523,7 +683,10 @@ module PWN
523
683
  to: 'optional - addressee recorded on the bus (default :all)',
524
684
  on_tool: 'optional - ->(name, args, result) live-UI callback',
525
685
  from: 'optional - sender account or address to bind as operator (defaults to caller_label)',
526
- text_only: 'required - text only value consumed by #ask'
686
+ text_only: 'required - text only value consumed by #ask',
687
+ unit: 'optional - claim key (host+phase) before the child runs',
688
+ ttl: 'optional - claim TTL seconds (defaults to 300)',
689
+ engagement_id: 'optional - claim namespace (defaults to swarm_id)'
527
690
  )
528
691
 
529
692
  # Run debate and return its result
@@ -571,6 +734,43 @@ module PWN
571
734
  agent_id: 'optional - claimant id'
572
735
  )
573
736
 
737
+ # Cap a child to 1-3 skills and drop swarm unless orchestrator.
738
+ #{self}.pack_specialist(
739
+ name: 'optional - specialist name',
740
+ skills: 'optional - Array of SOP skill names (kept at most 3)',
741
+ toolsets: 'optional - Registry toolset names',
742
+ orchestrator: 'optional - true to keep the swarm toolset'
743
+ )
744
+
745
+ # World-object inbox for a child session (finding ids, artifact shas).
746
+ #{self}.child_inbox(
747
+ session_id: 'optional - child PWN::Sessions id',
748
+ name: 'optional - persona name'
749
+ )
750
+
751
+ # Honesty gap when a pwn child filed no findings and no artifacts.
752
+ #{self}.child_honesty(
753
+ name: 'optional - persona name',
754
+ toolsets: 'optional - Array of toolset names',
755
+ session_id: 'optional - child session id',
756
+ skills: 'optional - Array of SOP skill names'
757
+ )
758
+
759
+ # Unmet tokens for parent Loop from child honesty gaps.
760
+ #{self}.honesty_unmet(
761
+ swarm_id: 'optional - swarm id whose children were recorded'
762
+ )
763
+
764
+ # Personas and live claims for a swarm (check before spawning).
765
+ #{self}.view_graph(
766
+ swarm_id: 'required - swarm id from #create'
767
+ )
768
+
769
+ # Patch stock escalator/scribe toolsets on an older agents.yml.
770
+ #{self}.migrate_personas(
771
+ path: 'optional - agents.yml path (defaults to ~/.pwn/agents.yml)'
772
+ )
773
+
574
774
  # Print the AUTHOR(S) string for this module.
575
775
  #{self}.authors
576
776
  "
@@ -188,9 +188,17 @@ module PWN
188
188
  opts[:args] || {}
189
189
  end
190
190
 
191
+ MAX_PAYLOAD_BYTES = 1_048_576
192
+
191
193
  public_class_method def self.invalid_payload(opts = {})
192
194
  hint = opts[:hint].to_s
193
195
  tok = opts[:offending_token].to_s
196
+ text = opts[:text].to_s
197
+ range = opts[:byte_range]
198
+ if range.nil? && !tok.empty? && !text.empty?
199
+ idx = text.index(tok)
200
+ range = [idx, idx + tok.bytesize] if idx
201
+ end
194
202
  {
195
203
  stdout: '',
196
204
  stderr: hint,
@@ -199,6 +207,8 @@ module PWN
199
207
  code: (opts[:code] || 'SYNTAX_DENY').to_s,
200
208
  rule_id: (opts[:rule_id] || 'payload').to_s,
201
209
  offending_token: tok,
210
+ byte_range: range,
211
+ max_payload_bytes: MAX_PAYLOAD_BYTES,
202
212
  suggestion: opts[:suggestion].to_s,
203
213
  hint: hint,
204
214
  shell: opts[:shell] || shell_name
@@ -649,7 +659,9 @@ module PWN
649
659
  code: 'optional - denial code (defaults to SYNTAX_DENY)',
650
660
  rule_id: 'optional - rule identifier (defaults to payload)',
651
661
  offending_token: 'optional - exact rejected token span',
652
- suggestion: 'optional - how to rewrite the payload'
662
+ suggestion: 'optional - how to rewrite the payload',
663
+ text: 'optional - original payload used to compute byte_range',
664
+ byte_range: 'optional - [start, stop] byte offsets of the offending span'
653
665
  )
654
666
 
655
667
  # Build a machine-readable guard denial (SCOPE_DENY, CANARY_DENY, ...).
@@ -45,17 +45,64 @@ PWN::AI::Agent::Registry.register(
45
45
  path: { type: 'string' },
46
46
  offset: { type: 'integer' },
47
47
  length: { type: 'integer' },
48
- mode: { type: 'string', description: 'text|hex|base64' }
48
+ mode: { type: 'string', description: 'text|hex|base64' },
49
+ grep: { type: 'string' },
50
+ ref: { type: 'string' },
51
+ sha256: { type: 'string' }
49
52
  },
50
53
  required: %w[path]
51
54
  }
52
55
  },
53
56
  handler: lambda { |args|
54
57
  PWN::Plugins::ArtifactRegistry.read_page(
55
- path: args[:path] || args['path'],
58
+ path: args[:path] || args['path'] || args[:ref] || args['ref'],
56
59
  offset: args[:offset] || args['offset'],
57
60
  length: args[:length] || args['length'],
58
- mode: args[:mode] || args['mode']
61
+ mode: args[:mode] || args['mode'],
62
+ grep: args[:grep] || args['grep'],
63
+ sha256: args[:sha256] || args['sha256']
59
64
  )
60
65
  }
61
66
  )
67
+ PWN::AI::Agent::Registry.register(
68
+ name: 'artifact_put',
69
+ toolset: 'sessions',
70
+ schema: {
71
+ name: 'artifact_put',
72
+ description: 'Store bytes or a file in the content-addressed artifact store.',
73
+ parameters: {
74
+ type: 'object',
75
+ properties: {
76
+ path: { type: 'string' },
77
+ bytes: { type: 'string' },
78
+ kind: { type: 'string' },
79
+ tags: { type: 'array', items: { type: 'string' } },
80
+ session_id: { type: 'string' }
81
+ }
82
+ }
83
+ },
84
+ handler: lambda { |args|
85
+ PWN::Plugins::ArtifactRegistry.put(
86
+ path: args[:path] || args['path'],
87
+ bytes: args[:bytes] || args['bytes'],
88
+ kind: args[:kind] || args['kind'],
89
+ tags: args[:tags] || args['tags'],
90
+ session_id: args[:session_id] || args['session_id']
91
+ )
92
+ }
93
+ )
94
+ PWN::AI::Agent::Registry.register(
95
+ name: 'artifact_get',
96
+ toolset: 'sessions',
97
+ schema: {
98
+ name: 'artifact_get',
99
+ description: 'Fetch an artifact by sha256 or path.',
100
+ parameters: {
101
+ type: 'object',
102
+ properties: { sha256: { type: 'string' }, path: { type: 'string' } }
103
+ }
104
+ },
105
+ handler: lambda { |args|
106
+ PWN::Plugins::ArtifactRegistry.get(sha256: args[:sha256] || args['sha256'], path: args[:path] || args['path'])
107
+ }
108
+ )
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'capabilities',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'capabilities',
10
+ description: 'Cached plugin/binary/CAP_* manifest (~/.pwn/capabilities.json) refreshed at session start.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: { refresh: { type: 'boolean' } }
14
+ }
15
+ },
16
+ handler: lambda { |args|
17
+ PWN::Plugins::PreflightChecker.manifest(refresh: args[:refresh] || args['refresh'])
18
+ }
19
+ )
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'context_attach',
7
+ toolset: 'sessions',
8
+ schema: {
9
+ name: 'context_attach',
10
+ description: 'Attach a file, hexdump, disassembly, or HTTP transcript to model context (chunked + SHA-256 store).',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ kind: { type: 'string', description: 'file|hexdump|disasm|http' },
15
+ path: { type: 'string' },
16
+ offset: { type: 'integer' },
17
+ length: { type: 'integer' },
18
+ function: { type: 'string' },
19
+ har_or_raw: { type: 'string' },
20
+ session_id: { type: 'string' }
21
+ },
22
+ required: %w[kind]
23
+ }
24
+ },
25
+ handler: lambda { |args|
26
+ kind = (args[:kind] || args['kind']).to_s
27
+ case kind
28
+ when 'hexdump'
29
+ PWN::AI::Context.attach_hexdump(path: args[:path], offset: args[:offset], length: args[:length], session_id: args[:session_id])
30
+ when 'disasm'
31
+ PWN::AI::Context.attach_disasm(path: args[:path], function: args[:function], session_id: args[:session_id])
32
+ when 'http'
33
+ PWN::AI::Context.attach_http_transcript(har_or_raw: args[:har_or_raw] || args[:path], session_id: args[:session_id])
34
+ else
35
+ PWN::AI::Context.attach_file(path: args[:path], session_id: args[:session_id])
36
+ end
37
+ }
38
+ )
@@ -67,3 +67,21 @@ PWN::AI::Agent::Registry.register(
67
67
  PWN::Plugins::Findings.report
68
68
  }
69
69
  )
70
+ PWN::AI::Agent::Registry.register(
71
+ name: 'chain_score',
72
+ toolset: 'pwn',
73
+ schema: {
74
+ name: 'chain_score',
75
+ description: 'Recompute combined severity when findings are chained (e.g. SSRF + metadata).',
76
+ parameters: {
77
+ type: 'object',
78
+ properties: {
79
+ ids: { type: 'array', items: { type: 'string' } },
80
+ chain_refs: { type: 'array', items: { type: 'string' } }
81
+ }
82
+ }
83
+ },
84
+ handler: lambda { |args|
85
+ PWN::Plugins::Findings.chain_score(ids: args[:ids] || args['ids'], chain_refs: args[:chain_refs] || args['chain_refs'])
86
+ }
87
+ )
@@ -32,7 +32,16 @@ PWN::AI::Agent::Registry.register(
32
32
  when 'status'
33
33
  row = PWN::Plugins::Jobs.status(id: args[:id] || args['id'])
34
34
  out_dir = args[:out_dir] || args['out_dir']
35
- out_dir ? row.merge(PWN::Plugins::AFLplusplus.parse_stats(out_dir: out_dir)) : row
35
+ row = row.merge(PWN::Plugins::AFLplusplus.parse_stats(out_dir: out_dir)) if out_dir
36
+ if out_dir && defined?(PWN::Plugins::AFLplusplus)
37
+ begin
38
+ triage = PWN::Plugins::AFLplusplus.crash_triage(out_dir: out_dir)
39
+ row = row.merge(triage: triage) if triage
40
+ rescue StandardError
41
+ nil
42
+ end
43
+ end
44
+ row
36
45
  when 'stop'
37
46
  PWN::Plugins::Jobs.stop(id: args[:id] || args['id'])
38
47
  when 'triage'
@@ -54,3 +54,35 @@ PWN::AI::Agent::Registry.register(
54
54
  PWN::Plugins::Jobs.result(id: args[:id] || args['id'], lines: args[:lines] || args['lines'])
55
55
  }
56
56
  )
57
+ PWN::AI::Agent::Registry.register(
58
+ name: 'job_tail',
59
+ toolset: 'terminal',
60
+ schema: {
61
+ name: 'job_tail',
62
+ description: 'Tail a job log started by job_run.',
63
+ parameters: {
64
+ type: 'object',
65
+ properties: { id: { type: 'string' }, handle: { type: 'string' }, lines: { type: 'integer' } },
66
+ required: %w[id]
67
+ }
68
+ },
69
+ handler: lambda { |args|
70
+ PWN::Plugins::Jobs.job_tail(id: args[:id] || args[:handle] || args['id'], lines: args[:lines] || args['lines'])
71
+ }
72
+ )
73
+ PWN::AI::Agent::Registry.register(
74
+ name: 'job_kill',
75
+ toolset: 'terminal',
76
+ schema: {
77
+ name: 'job_kill',
78
+ description: 'Stop a job started by job_run.',
79
+ parameters: {
80
+ type: 'object',
81
+ properties: { id: { type: 'string' }, handle: { type: 'string' } },
82
+ required: %w[id]
83
+ }
84
+ },
85
+ handler: lambda { |args|
86
+ PWN::Plugins::Jobs.job_kill(id: args[:id] || args[:handle] || args['id'])
87
+ }
88
+ )
@@ -14,10 +14,9 @@ PWN::AI::Agent::Registry.register(
14
14
  toolset: 'learning',
15
15
  schema: {
16
16
  name: 'learning_note_outcome',
17
- description: 'Record the outcome of a task attempt so future pwn-ai ' \
18
- 'runs learn from it. Successes reinforce approaches; ' \
19
- 'failures become avoidance lessons injected into every ' \
20
- 'subsequent system prompt.',
17
+ description: 'Record an unverified, self-reported note about a task attempt. ' \
18
+ 'Preserves the claimed result for later review, but does not ' \
19
+ 'count as verified success or train future approaches.',
21
20
  parameters: {
22
21
  type: 'object',
23
22
  properties: {
@@ -33,8 +32,8 @@ PWN::AI::Agent::Registry.register(
33
32
  handler: lambda { |args|
34
33
  PWN::AI::Agent::Learning.note_outcome(
35
34
  task: args[:task],
36
- success: args[:success],
37
- details: args[:details],
35
+ outcome: { score: nil, source: :self_report, confidence: 0.0, reported_success: args[:success] },
36
+ details: "Self-reported claim (unverified): reported_success=#{args[:success]}. #{args[:details]}",
38
37
  tags: args[:tags]
39
38
  )
40
39
  }