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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/bin/pwn_setup +5 -5
- data/documentation/AI-Integration.md +34 -1
- data/documentation/Policy-Benchmark.md +151 -0
- data/documentation/Reinforcement-Learning.md +27 -1
- data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
- data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
- data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
- data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
- data/lib/pwn/ai/agent/curriculum.rb +37 -45
- data/lib/pwn/ai/agent/dispatch.rb +9 -0
- data/lib/pwn/ai/agent/engagement.rb +59 -0
- data/lib/pwn/ai/agent/learning.rb +111 -44
- data/lib/pwn/ai/agent/loop.rb +50 -8
- data/lib/pwn/ai/agent/metrics.rb +70 -2
- data/lib/pwn/ai/agent/mistakes.rb +67 -1
- data/lib/pwn/ai/agent/policy.rb +142 -16
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/reward.rb +153 -53
- data/lib/pwn/ai/agent/swarm.rb +235 -35
- data/lib/pwn/ai/agent/tool_guard.rb +13 -1
- data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
- data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
- data/lib/pwn/ai/agent/tools/context.rb +38 -0
- data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
- data/lib/pwn/ai/agent/tools/learning.rb +5 -6
- data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
- data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
- data/lib/pwn/ai/agent/tools/shell.rb +10 -1
- data/lib/pwn/ai/agent/tools/skills.rb +30 -0
- data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
- data/lib/pwn/ai/context.rb +193 -0
- data/lib/pwn/ai/http_retry.rb +53 -7
- data/lib/pwn/ai/open_ai.rb +302 -45
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/migrate.rb +10 -1
- data/lib/pwn/plugins/artifact_registry.rb +40 -17
- data/lib/pwn/plugins/binary_parser.rb +4 -1
- data/lib/pwn/plugins/exploit_dev.rb +15 -0
- data/lib/pwn/plugins/findings.rb +48 -8
- data/lib/pwn/plugins/gdb.rb +17 -0
- data/lib/pwn/plugins/gdbmi.rb +128 -0
- data/lib/pwn/plugins/ghidra_headless.rb +104 -0
- data/lib/pwn/plugins/jobs.rb +72 -0
- data/lib/pwn/plugins/packet.rb +51 -0
- data/lib/pwn/plugins/preflight_checker.rb +29 -0
- data/lib/pwn/plugins/process_tube.rb +24 -7
- data/lib/pwn/plugins/radare2.rb +14 -2
- data/lib/pwn/plugins/repl.rb +6 -1
- data/lib/pwn/plugins/transparent_browser.rb +64 -0
- data/lib/pwn/plugins.rb +2 -0
- data/lib/pwn/reports/engagement.rb +19 -0
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/scripts/benchmark_policy.rb +276 -0
- data/spec/documentation/installation_md_spec.rb +18 -4
- data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
- data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
- data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
- data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
- data/spec/lib/pwn/ai/context_spec.rb +48 -0
- data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
- data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
- data/spec/lib/pwn/migrate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
- data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
- data/third_party/pwn_rdoc.jsonl +86 -2
- metadata +24 -5
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
145
|
+
content: raw
|
|
139
146
|
}
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
341
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
18
|
-
'
|
|
19
|
-
'
|
|
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
|
-
|
|
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
|
}
|