pwn 0.5.718 → 0.5.720
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/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
- data/lib/pwn/ai/agent/dispatch.rb +67 -0
- data/lib/pwn/ai/agent/engagement.rb +167 -0
- data/lib/pwn/ai/agent/learning.rb +88 -4
- data/lib/pwn/ai/agent/loop.rb +20 -2
- data/lib/pwn/ai/agent/reward.rb +76 -12
- data/lib/pwn/ai/agent/swarm.rb +36 -0
- data/lib/pwn/ai/agent/tool_guard.rb +39 -0
- data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
- data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +65 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/config.rb +18 -2
- data/lib/pwn/plugins/bin_workspace.rb +81 -0
- data/lib/pwn/plugins/binary_parser.rb +27 -0
- data/lib/pwn/plugins/capability.rb +68 -0
- data/lib/pwn/plugins/detect_os.rb +1 -2
- data/lib/pwn/plugins/detonate.rb +62 -0
- data/lib/pwn/plugins/emulator.rb +64 -0
- data/lib/pwn/plugins/exploit_db.rb +33 -9
- data/lib/pwn/plugins/findings.rb +47 -0
- data/lib/pwn/plugins/gdb.rb +42 -0
- data/lib/pwn/plugins/packet.rb +26 -0
- data/lib/pwn/plugins/repl.rb +25 -2
- data/lib/pwn/plugins/vault.rb +116 -0
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +63 -0
- data/lib/pwn/reports.rb +1 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/reward_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
- data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
- data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
- data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
- data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -14
- data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
- data/spec/lib/pwn/plugins/exploit_db_spec.rb +38 -3
- data/spec/lib/pwn/plugins/findings_spec.rb +13 -0
- data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
- data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
- data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
- data/third_party/pwn_rdoc.jsonl +63 -0
- metadata +25 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module PWN
|
|
9
|
+
module AI
|
|
10
|
+
module Agent
|
|
11
|
+
# First-class engagement scope, RoE, and findings pointer.
|
|
12
|
+
module Engagement
|
|
13
|
+
ROOT = File.join(Dir.home, '.pwn', 'engagements')
|
|
14
|
+
ACTIVE_FILE = File.join(ROOT, 'active')
|
|
15
|
+
|
|
16
|
+
public_class_method def self.open(opts = {})
|
|
17
|
+
name = (opts[:name] || opts[:engagement] || 'default').to_s
|
|
18
|
+
raise 'ERROR: name is required' if name.empty?
|
|
19
|
+
|
|
20
|
+
FileUtils.mkdir_p(ROOT)
|
|
21
|
+
path = File.join(ROOT, "#{name}.json")
|
|
22
|
+
row = File.file?(path) ? JSON.parse(File.read(path), symbolize_names: true) : {}
|
|
23
|
+
row[:name] = name
|
|
24
|
+
row[:scope_cidrs] = Array(opts[:scope_cidrs] || row[:scope_cidrs])
|
|
25
|
+
row[:scope_domains] = Array(opts[:scope_domains] || row[:scope_domains])
|
|
26
|
+
row[:excluded] = Array(opts[:excluded] || row[:excluded])
|
|
27
|
+
row[:roe] = (opts[:roe] || row[:roe]).to_s
|
|
28
|
+
row[:window] = opts[:window] || row[:window]
|
|
29
|
+
row[:findings] ||= []
|
|
30
|
+
File.write(path, JSON.pretty_generate(row))
|
|
31
|
+
File.write(ACTIVE_FILE, name)
|
|
32
|
+
row.merge(path: path, active: true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
public_class_method def self.close(opts = {})
|
|
36
|
+
FileUtils.rm_f(ACTIVE_FILE) if opts.is_a?(Hash)
|
|
37
|
+
{ active: nil }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
public_class_method def self.status(opts = {})
|
|
41
|
+
name = (opts[:name] || current_name).to_s
|
|
42
|
+
return { active: nil } if name.empty?
|
|
43
|
+
|
|
44
|
+
path = File.join(ROOT, "#{name}.json")
|
|
45
|
+
return { active: name, missing: true } unless File.file?(path)
|
|
46
|
+
|
|
47
|
+
JSON.parse(File.read(path), symbolize_names: true).merge(active: name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
public_class_method def self.current_name(opts = {})
|
|
51
|
+
return nil unless opts.is_a?(Hash)
|
|
52
|
+
return nil unless File.file?(ACTIVE_FILE)
|
|
53
|
+
|
|
54
|
+
File.read(ACTIVE_FILE).strip
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
public_class_method def self.in_scope?(opts = {})
|
|
58
|
+
token = (opts[:host] || opts[:ip] || opts[:target] || opts[:url]).to_s
|
|
59
|
+
return true if token.empty?
|
|
60
|
+
|
|
61
|
+
row = status
|
|
62
|
+
return true if row[:active].nil? || row[:missing]
|
|
63
|
+
|
|
64
|
+
enforce = engagement_enforce
|
|
65
|
+
return true if enforce == 'off'
|
|
66
|
+
|
|
67
|
+
host = token
|
|
68
|
+
begin
|
|
69
|
+
host = URI.parse(token).host || token if token.include?('://')
|
|
70
|
+
rescue StandardError
|
|
71
|
+
host = token
|
|
72
|
+
end
|
|
73
|
+
host = host.sub(%r{\Ahttps?://}i, '').split('/').first.to_s.split(':').first
|
|
74
|
+
return false if Array(row[:excluded]).any? { |ex| host.include?(ex.to_s) }
|
|
75
|
+
|
|
76
|
+
cidrs = Array(row[:scope_cidrs]).map(&:to_s).reject(&:empty?)
|
|
77
|
+
domains = Array(row[:scope_domains]).map(&:to_s).reject(&:empty?)
|
|
78
|
+
return true if cidrs.empty? && domains.empty?
|
|
79
|
+
|
|
80
|
+
ip_ok = begin
|
|
81
|
+
addr = IPAddr.new(host)
|
|
82
|
+
cidrs.any? { |c| IPAddr.new(c).include?(addr) }
|
|
83
|
+
rescue StandardError
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
dom_ok = domains.any? { |d| host == d || host.end_with?(".#{d}") }
|
|
87
|
+
ip_ok || dom_ok
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
public_class_method def self.deny_if_out_of_scope(opts = {})
|
|
91
|
+
args = opts[:args] || opts[:command] || opts[:text]
|
|
92
|
+
blob = args.is_a?(Hash) ? args.inspect : args.to_s
|
|
93
|
+
tokens = blob.scan(/(?:\d{1,3}\.){3}\d{1,3}|[A-Za-z0-9.-]+\.[A-Za-z]{2,}/).uniq
|
|
94
|
+
bad = tokens.reject { |tok| in_scope?(host: tok) }
|
|
95
|
+
return nil if bad.empty?
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
success: false,
|
|
99
|
+
error: "out_of_scope: #{bad.first}",
|
|
100
|
+
code: 'SCOPE_DENY',
|
|
101
|
+
violating: bad
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
public_class_method def self.authors
|
|
106
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
public_class_method def self.help
|
|
110
|
+
puts "USAGE:
|
|
111
|
+
# Open or update an engagement and mark it active.
|
|
112
|
+
#{self}.open(
|
|
113
|
+
name: 'required - engagement name',
|
|
114
|
+
engagement: 'optional - alias for name',
|
|
115
|
+
scope_cidrs: 'optional - Array of CIDR strings',
|
|
116
|
+
scope_domains: 'optional - Array of DNS suffixes',
|
|
117
|
+
excluded: 'optional - Array of excluded hosts',
|
|
118
|
+
roe: 'optional - rules of engagement text',
|
|
119
|
+
window: 'optional - time window string'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Clear the active engagement pointer.
|
|
123
|
+
#{self}.close(
|
|
124
|
+
name: 'optional - unused; closing always clears the active pointer'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Return the active engagement document.
|
|
128
|
+
#{self}.status(
|
|
129
|
+
name: 'optional - engagement name (defaults to active)'
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Return the active engagement name or nil.
|
|
133
|
+
#{self}.current_name(
|
|
134
|
+
unused: 'optional - reserved'
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# True when host/ip/url is inside the active engagement scope.
|
|
138
|
+
#{self}.in_scope?(
|
|
139
|
+
host: 'optional - DNS hostname to test against scope_domains',
|
|
140
|
+
ip: 'optional - IPv4 or IPv6 address to test against scope_cidrs',
|
|
141
|
+
target: 'optional - host or IP alias when the caller has one field',
|
|
142
|
+
url: 'optional - URL whose host is extracted and tested'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Structured denial when args mention an out-of-scope host.
|
|
146
|
+
#{self}.deny_if_out_of_scope(
|
|
147
|
+
args: 'optional - Hash of tool args',
|
|
148
|
+
command: 'optional - command string',
|
|
149
|
+
text: 'optional - free-form blob to scan'
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Print the AUTHOR(S) string for this module.
|
|
153
|
+
#{self}.authors
|
|
154
|
+
"
|
|
155
|
+
constants.sort
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private_class_method def self.engagement_enforce
|
|
159
|
+
v = (PWN::Env.dig(:ai, :engagement, :enforce) if defined?(PWN::Env))
|
|
160
|
+
(v || 'block').to_s
|
|
161
|
+
rescue StandardError
|
|
162
|
+
'block'
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -128,6 +128,16 @@ module PWN
|
|
|
128
128
|
entry[:score] = opts[:score].to_f if opts.key?(:score)
|
|
129
129
|
src = opts[:judge_source].to_s
|
|
130
130
|
entry[:judge_source] = src unless src.empty?
|
|
131
|
+
vv = (opts[:verifier_verdict] || opts['verifier_verdict']).to_s
|
|
132
|
+
entry[:verifier_verdict] = vv unless vv.empty?
|
|
133
|
+
vc = (opts[:verdict_class] || opts['verdict_class']).to_s
|
|
134
|
+
entry[:verdict_class] = vc unless vc.empty?
|
|
135
|
+
entry[:remediation_hint] = opts[:remediation_hint].to_s unless opts[:remediation_hint].to_s.empty?
|
|
136
|
+
if vv == 'pass' && opts.key?(:score) && opts[:score].to_f < 0.6
|
|
137
|
+
entry[:status] = 'conflicted'
|
|
138
|
+
entry[:success] = true
|
|
139
|
+
success = true
|
|
140
|
+
end
|
|
131
141
|
FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
|
|
132
142
|
File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
|
|
133
143
|
maybe_prune_outcomes!
|
|
@@ -137,7 +147,7 @@ module PWN
|
|
|
137
147
|
# are promoted into PWN::Memory[:lesson] so PromptBuilder recall
|
|
138
148
|
# survives across sessions. Without this, the agent re-learns
|
|
139
149
|
# "run rubocop after every patch" every turn (empty memory.json).
|
|
140
|
-
promote_process_lesson(entry: entry) if defined?(PWN::Memory)
|
|
150
|
+
promote_process_lesson(entry: entry) if defined?(PWN::Memory) && entry[:status].to_s != 'conflicted'
|
|
141
151
|
if opts.key?(:score) && defined?(Curriculum) && Curriculum.respond_to?(:calibrate)
|
|
142
152
|
pred = opts[:predicted]
|
|
143
153
|
pred = Thread.current[:pwn_plan_predicted] if pred.nil?
|
|
@@ -215,6 +225,8 @@ module PWN
|
|
|
215
225
|
# envelope rows (REQUEST:/GOAL: prefixes) without starving the block.
|
|
216
226
|
rows = prefer_primary_tasks(rows: outcomes(limit: limit * 4)).first(limit)
|
|
217
227
|
fails = prefer_primary_tasks(rows: outcomes(limit: 200, success: false))
|
|
228
|
+
fails = fails.reject { |r| r[:status].to_s == 'conflicted' }
|
|
229
|
+
fails = fails.reject { |r| r[:verifier_verdict].to_s == 'pass' }
|
|
218
230
|
# Do not mirror the same ids under both headings — that doubled the
|
|
219
231
|
# failure signal and made RECENT OUTCOMES == RECENT FAILURES when the
|
|
220
232
|
# last N attempts all failed (the injected block looked "stuck").
|
|
@@ -234,8 +246,12 @@ module PWN
|
|
|
234
246
|
# Surface a one-line cause crumb so the agent can actually learn
|
|
235
247
|
# from failures instead of only seeing that they failed.
|
|
236
248
|
if r[:success] != true
|
|
237
|
-
|
|
238
|
-
|
|
249
|
+
if r[:verdict_class].to_s == ''
|
|
250
|
+
crumb = cause_crumb(details: r[:details])
|
|
251
|
+
line += "\n cause: #{crumb}" unless crumb.empty?
|
|
252
|
+
else
|
|
253
|
+
line += "\n cause: #{r[:verdict_class]} #{r[:remediation_hint]}"
|
|
254
|
+
end
|
|
239
255
|
end
|
|
240
256
|
line
|
|
241
257
|
end
|
|
@@ -1049,6 +1065,7 @@ module PWN
|
|
|
1049
1065
|
|
|
1050
1066
|
private_class_method def self.cause_crumb(opts = {})
|
|
1051
1067
|
d = opts[:details].to_s.gsub(/\s+/, ' ').strip
|
|
1068
|
+
d = d.gsub(/overlap=\S+/, '').gsub(/ratio=\S+/, '').strip
|
|
1052
1069
|
return '' if d.empty?
|
|
1053
1070
|
|
|
1054
1071
|
# Prefer explicit FLAW / CORRECTED crumbs; else verdict(score) head.
|
|
@@ -1818,6 +1835,55 @@ module PWN
|
|
|
1818
1835
|
File.write(LESSONS_FILE, JSON.pretty_generate(opts[:store]))
|
|
1819
1836
|
end
|
|
1820
1837
|
|
|
1838
|
+
private_class_method def self.learning_max_baks(opts = {})
|
|
1839
|
+
n = opts[:max_baks]
|
|
1840
|
+
return n.to_i if n.to_i.positive?
|
|
1841
|
+
return 5 unless defined?(PWN::Env)
|
|
1842
|
+
|
|
1843
|
+
v = PWN::Env.dig(:ai, :learning, :max_baks)
|
|
1844
|
+
v.to_i.positive? ? v.to_i : 5
|
|
1845
|
+
rescue StandardError
|
|
1846
|
+
5
|
|
1847
|
+
end
|
|
1848
|
+
|
|
1849
|
+
public_class_method def self.list_conflicted(opts = {})
|
|
1850
|
+
limit = (opts[:limit] || 50).to_i
|
|
1851
|
+
outcomes(limit: 500).select { |r| r[:status].to_s == 'conflicted' }.first(limit)
|
|
1852
|
+
end
|
|
1853
|
+
|
|
1854
|
+
public_class_method def self.requeue_conflicted(opts = {})
|
|
1855
|
+
dry = opts[:dry_run] ? true : false
|
|
1856
|
+
rows = list_conflicted(limit: 10_000)
|
|
1857
|
+
return { rescored: 0, dry_run: dry } if rows.empty? || dry
|
|
1858
|
+
|
|
1859
|
+
n = 0
|
|
1860
|
+
rows.each do |r|
|
|
1861
|
+
note_outcome(
|
|
1862
|
+
task: "requeue:#{r[:task]}",
|
|
1863
|
+
success: true,
|
|
1864
|
+
score: [r[:score].to_f, 0.7].max,
|
|
1865
|
+
verifier_verdict: :pass,
|
|
1866
|
+
details: 'rescored after verifier precedence',
|
|
1867
|
+
tags: %w[requeue]
|
|
1868
|
+
)
|
|
1869
|
+
n += 1
|
|
1870
|
+
end
|
|
1871
|
+
{ rescored: n, dry_run: false }
|
|
1872
|
+
end
|
|
1873
|
+
|
|
1874
|
+
public_class_method def self.compact!(opts = {})
|
|
1875
|
+
max_baks = (opts[:max_baks] || learning_max_baks).to_i
|
|
1876
|
+
max_baks = 5 if max_baks <= 0
|
|
1877
|
+
dir = File.dirname(LEARNING_FILE)
|
|
1878
|
+
baks = Dir[File.join(dir, '*.bak*')].sort_by { |p| File.mtime(p) }.reverse
|
|
1879
|
+
pruned = 0
|
|
1880
|
+
baks.drop(max_baks).each do |path|
|
|
1881
|
+
File.delete(path)
|
|
1882
|
+
pruned += 1
|
|
1883
|
+
end
|
|
1884
|
+
{ pruned: pruned, kept: [baks.length, max_baks].min, max_baks: max_baks }
|
|
1885
|
+
end
|
|
1886
|
+
|
|
1821
1887
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
1822
1888
|
|
|
1823
1889
|
public_class_method def self.authors
|
|
@@ -1839,7 +1905,25 @@ module PWN
|
|
|
1839
1905
|
judge_source: 'required - judge source value consumed by #note_outcome',
|
|
1840
1906
|
predicted: 'optional - predicted value consumed by #note_outcome',
|
|
1841
1907
|
confidence: 'optional - confidence value consumed by #note_outcome',
|
|
1842
|
-
engine: 'optional - engine value consumed by #note_outcome'
|
|
1908
|
+
engine: 'optional - engine value consumed by #note_outcome',
|
|
1909
|
+
verifier_verdict: 'optional - pass|fail from a deterministic verifier',
|
|
1910
|
+
verdict_class: 'optional - missing_artifact|wrong_path|unverified_claim|scope_miss|partial_coverage|style_only',
|
|
1911
|
+
remediation_hint: 'optional - one-line fix hint'
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
# List outcomes tagged conflicted (verifier PASS vs low judge).
|
|
1915
|
+
#{self}.list_conflicted(
|
|
1916
|
+
limit: 'optional - max entries (defaults to 50)'
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
# Rescore conflicted outcomes after verifier-precedence lands.
|
|
1920
|
+
#{self}.requeue_conflicted(
|
|
1921
|
+
dry_run: 'optional - true to count without writing'
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
# Prune excess *.bak siblings under the learning directory.
|
|
1925
|
+
#{self}.compact!(
|
|
1926
|
+
max_baks: 'optional - newest bak files to keep (defaults to 5)'
|
|
1843
1927
|
)
|
|
1844
1928
|
|
|
1845
1929
|
# Run outcomes and return its result
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -661,7 +661,6 @@ module PWN
|
|
|
661
661
|
|
|
662
662
|
private_class_method def self.declared_contract_unsatisfied?(opts = {})
|
|
663
663
|
return false if verify_passed?(messages: opts[:messages])
|
|
664
|
-
return false if write_or_read_evidenced?(messages: opts[:messages])
|
|
665
664
|
|
|
666
665
|
completion_unmet(opts).any?
|
|
667
666
|
rescue StandardError
|
|
@@ -670,7 +669,6 @@ module PWN
|
|
|
670
669
|
|
|
671
670
|
private_class_method def self.completion_unmet(opts = {})
|
|
672
671
|
return [] if verify_passed?(messages: opts[:messages])
|
|
673
|
-
return [] if write_or_read_evidenced?(messages: opts[:messages])
|
|
674
672
|
|
|
675
673
|
contract = declared_contract(request: opts[:request])
|
|
676
674
|
request = opts[:request].to_s
|
|
@@ -3145,6 +3143,20 @@ module PWN
|
|
|
3145
3143
|
TurnFinalizer.leave_user_path! if defined?(TurnFinalizer)
|
|
3146
3144
|
end
|
|
3147
3145
|
|
|
3146
|
+
public_class_method def self.budget_status(opts = {})
|
|
3147
|
+
t0 = opts[:t0] || Thread.current[:pwn_loop_t0] || Time.now
|
|
3148
|
+
elapsed = Time.now - t0
|
|
3149
|
+
remain = (opts[:remaining_s] || Thread.current[:pwn_loop_budget_s] || 10_800).to_f - elapsed
|
|
3150
|
+
{
|
|
3151
|
+
elapsed_s: elapsed.round,
|
|
3152
|
+
remaining_tool_budget_s: [remain, 0].max.round,
|
|
3153
|
+
mutations_used: Thread.current[:pwn_loop_mutations].to_i,
|
|
3154
|
+
mutations_max: 10,
|
|
3155
|
+
context_tokens_used: Thread.current[:pwn_loop_tokens].to_i,
|
|
3156
|
+
est_max: 128_000
|
|
3157
|
+
}
|
|
3158
|
+
end
|
|
3159
|
+
|
|
3148
3160
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
3149
3161
|
|
|
3150
3162
|
public_class_method def self.authors
|
|
@@ -3203,6 +3215,12 @@ module PWN
|
|
|
3203
3215
|
force_tools: 'optional - force tools value consumed by #run'
|
|
3204
3216
|
)
|
|
3205
3217
|
|
|
3218
|
+
# Remaining time/token/mutation budget for the current loop.
|
|
3219
|
+
#{self}.budget_status(
|
|
3220
|
+
t0: 'optional - session start Time (defaults to thread t0)',
|
|
3221
|
+
remaining_s: 'optional - override remaining tool budget seconds'
|
|
3222
|
+
)
|
|
3223
|
+
|
|
3206
3224
|
# Print the AUTHOR(S) string for this module.
|
|
3207
3225
|
#{self}.authors
|
|
3208
3226
|
"
|
data/lib/pwn/ai/agent/reward.rb
CHANGED
|
@@ -177,22 +177,36 @@ module PWN
|
|
|
177
177
|
v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
206
|
v[:score] = [v[:score].to_f, 0.7].max
|
|
195
207
|
v[:score] = [v[:score].to_f, 0.70].min
|
|
208
|
+
v[:success] = true if prec
|
|
209
|
+
v[:verifier_verdict] ||= :pass
|
|
196
210
|
end
|
|
197
211
|
v[:score_components] ||= {
|
|
198
212
|
judge: v[:score].to_f,
|
|
@@ -201,7 +215,16 @@ module PWN
|
|
|
201
215
|
weights: { overlap: pass ? 0.0 : 0.15 }
|
|
202
216
|
}
|
|
203
217
|
v[:score_components][:weights][:overlap] = 0.0 if pass
|
|
204
|
-
|
|
218
|
+
if commit && defined?(Learning) && opts[:persist_components]
|
|
219
|
+
Learning.note_outcome(
|
|
220
|
+
task: request[0, 80],
|
|
221
|
+
success: v[:success],
|
|
222
|
+
score: v[:score],
|
|
223
|
+
details: v[:score_components].to_json,
|
|
224
|
+
verifier_verdict: v[:verifier_verdict],
|
|
225
|
+
verdict_class: v[:verdict_class]
|
|
226
|
+
)
|
|
227
|
+
end
|
|
205
228
|
# W3 — write Brier on every judged turn so overconfidence can
|
|
206
229
|
# throttle max_iters/critic even when plan_first never fired.
|
|
207
230
|
if commit
|
|
@@ -1254,6 +1277,45 @@ module PWN
|
|
|
1254
1277
|
false
|
|
1255
1278
|
end
|
|
1256
1279
|
|
|
1280
|
+
private_class_method def self.verifier_precedence?(opts = {})
|
|
1281
|
+
return true unless opts.is_a?(Hash)
|
|
1282
|
+
return true unless defined?(PWN::Env)
|
|
1283
|
+
|
|
1284
|
+
v = PWN::Env.dig(:ai, :reward, :verifier_precedence)
|
|
1285
|
+
v != false
|
|
1286
|
+
rescue StandardError
|
|
1287
|
+
true
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
private_class_method def self.taxonomy_class(opts = {})
|
|
1291
|
+
given = (opts[:verdict_class] || opts['verdict_class']).to_s
|
|
1292
|
+
return given unless given.empty?
|
|
1293
|
+
|
|
1294
|
+
vv = opts[:verifier_verdict]
|
|
1295
|
+
score = opts[:score].to_f
|
|
1296
|
+
final = opts[:final].to_s
|
|
1297
|
+
request = opts[:request].to_s
|
|
1298
|
+
return 'unverified_claim' if vv == :pass && score < 0.6
|
|
1299
|
+
return 'missing_artifact' if request.match?(%r{/(?:tmp|home|opt|var)/}) && !final.match?(%r{/(?:tmp|home|opt|var)/})
|
|
1300
|
+
return 'wrong_path' if request.include?('/tmp/') && final.include?('/tmp/') && request.split.none? { |tok| tok.start_with?('/') && final.include?(tok) }
|
|
1301
|
+
return 'scope_miss' if final.match?(/out of scope|SCOPE_DENY/i)
|
|
1302
|
+
return 'partial_coverage' if score.between?(0.3, 0.59)
|
|
1303
|
+
return 'style_only' if score >= 0.6 && Array(opts[:trace]).empty?
|
|
1304
|
+
|
|
1305
|
+
'unverified_claim'
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
private_class_method def self.taxonomy_hint(opts = {})
|
|
1309
|
+
{
|
|
1310
|
+
'missing_artifact' => 'Write the requested path then read it back.',
|
|
1311
|
+
'wrong_path' => 'Use the absolute path named in the original request.',
|
|
1312
|
+
'unverified_claim' => 'Add a verifier PASS (file readback, exit 0, or hash match).',
|
|
1313
|
+
'scope_miss' => 'Stay inside the active engagement scope.',
|
|
1314
|
+
'partial_coverage' => 'Finish remaining request clauses before claiming done.',
|
|
1315
|
+
'style_only' => 'Produce a host-visible artifact, not prose restyling.'
|
|
1316
|
+
}[opts[:verdict_class].to_s] || 'Produce evidence that matches the original request.'
|
|
1317
|
+
end
|
|
1318
|
+
|
|
1257
1319
|
private_class_method def self.cheap_orm_timeout
|
|
1258
1320
|
n = agent_flag(key: :reward_llm_timeout, default: CHEAP_ORM_TIMEOUT).to_i
|
|
1259
1321
|
n = CHEAP_ORM_TIMEOUT if n < 2
|
|
@@ -1770,7 +1832,9 @@ module PWN
|
|
|
1770
1832
|
critic_pass: 'optional - critic pass value consumed by #judge',
|
|
1771
1833
|
predicted: 'optional - predicted value consumed by #judge',
|
|
1772
1834
|
proxy_ok: 'optional - proxy ok value consumed by #judge',
|
|
1773
|
-
persist_components: 'optional - write score_components into the learning ledger'
|
|
1835
|
+
persist_components: 'optional - write score_components into the learning ledger',
|
|
1836
|
+
verifier_verdict: 'optional - :pass when a deterministic verifier already succeeded',
|
|
1837
|
+
verifier_pass: 'optional - true as a boolean alias for verifier_verdict :pass'
|
|
1774
1838
|
)
|
|
1775
1839
|
|
|
1776
1840
|
# Run promote to success and return its result
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -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
|
"
|
|
@@ -442,6 +442,39 @@ module PWN
|
|
|
442
442
|
nil
|
|
443
443
|
end
|
|
444
444
|
|
|
445
|
+
public_class_method def self.policy_decision(opts = {})
|
|
446
|
+
name = opts[:name].to_s
|
|
447
|
+
blob = opts[:args].inspect
|
|
448
|
+
path = File.join(Dir.home, '.pwn', 'toolguard.yaml')
|
|
449
|
+
return nil unless File.file?(path)
|
|
450
|
+
|
|
451
|
+
require 'yaml'
|
|
452
|
+
doc = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
|
|
453
|
+
Array(doc['rules'] || doc[:rules]).each do |rule|
|
|
454
|
+
next unless rule.is_a?(Hash)
|
|
455
|
+
|
|
456
|
+
tool = (rule['tool'] || rule[:tool]).to_s
|
|
457
|
+
next unless tool.empty? || tool == name
|
|
458
|
+
|
|
459
|
+
pat = (rule['arg_pattern'] || rule[:arg_pattern]).to_s
|
|
460
|
+
next if pat.empty? || blob.match?(Regexp.new(pat)) == false
|
|
461
|
+
|
|
462
|
+
action = (rule['action'] || rule[:action] || 'allow').to_s
|
|
463
|
+
next if action == 'allow'
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
action: action,
|
|
467
|
+
success: false,
|
|
468
|
+
error: (rule['reason'] || rule[:reason] || 'toolguard policy deny').to_s,
|
|
469
|
+
code: 'POLICY_DENY',
|
|
470
|
+
tool: name
|
|
471
|
+
}
|
|
472
|
+
end
|
|
473
|
+
nil
|
|
474
|
+
rescue StandardError
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
445
478
|
public_class_method def self.rfc1918?(opts = {})
|
|
446
479
|
oct = opts[:ip].to_s.split('.').map(&:to_i)
|
|
447
480
|
return false unless oct.length == 4
|
|
@@ -690,6 +723,12 @@ module PWN
|
|
|
690
723
|
payload: 'optional - command string if class omitted'
|
|
691
724
|
)
|
|
692
725
|
|
|
726
|
+
# Evaluate ~/.pwn/toolguard.yaml first-match rules.
|
|
727
|
+
#{self}.policy_decision(
|
|
728
|
+
name: 'required - tool name',
|
|
729
|
+
args: 'required - Hash of tool arguments'
|
|
730
|
+
)
|
|
731
|
+
|
|
693
732
|
# Print the AUTHOR(S) string for this module.
|
|
694
733
|
#{self}.authors
|
|
695
734
|
"
|