pwn 0.5.686 → 0.5.688
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/.gitignore +2 -0
- data/.ruby-version +1 -1
- data/Gemfile +3 -3
- data/README.md +3 -4
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Configuration.md +5 -4
- data/documentation/General-PWN-Usage.md +1 -1
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Mistakes.md +1 -1
- data/documentation/Reinforcement-Learning.md +4 -6
- data/documentation/Troubleshooting.md +1 -1
- data/documentation/pwn-REPL.md +1 -1
- data/documentation/pwn-ai-Agent.md +2 -3
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/dispatch.rb +1 -1
- data/lib/pwn/ai/agent/learning.rb +150 -32
- data/lib/pwn/ai/agent/loop.rb +291 -129
- data/lib/pwn/ai/agent/prompt_builder.rb +47 -29
- data/lib/pwn/ai/agent/registry.rb +2 -1
- data/lib/pwn/ai/agent/reward.rb +9 -0
- data/lib/pwn/ai/agent/swarm.rb +3 -1
- data/lib/pwn/ai/agent/task_summarizer.rb +23 -6
- data/lib/pwn/ai/agent/tool_guard.rb +109 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +44 -20
- data/lib/pwn/ai/agent/tools/shell.rb +17 -4
- data/lib/pwn/ai/agent/tools/skills.rb +63 -2
- data/lib/pwn/ai/anthropic.rb +25 -5
- data/lib/pwn/ai/gemini.rb +25 -5
- data/lib/pwn/ai/grok.rb +30 -13
- data/lib/pwn/ai/http_retry.rb +78 -0
- data/lib/pwn/ai/ollama.rb +25 -5
- data/lib/pwn/ai/open_ai.rb +26 -13
- data/lib/pwn/ai/open_web_ui.rb +25 -5
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +1 -1
- data/lib/pwn/plugins/log.rb +183 -41
- data/lib/pwn/plugins/repl.rb +50 -15
- data/lib/pwn/plugins/transparent_browser.rb +16 -2
- data/lib/pwn/sessions.rb +50 -1
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +9 -11
- data/spec/lib/pwn/ai/agent/loop_spec.rb +180 -20
- data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +3 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/reward_spec.rb +13 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +6 -0
- data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +24 -0
- data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +29 -0
- data/spec/lib/pwn/ai/grok_spec.rb +22 -0
- data/spec/lib/pwn/ai/http_retry_spec.rb +55 -0
- data/spec/lib/pwn/plugins/log_spec.rb +170 -19
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +14 -4
- data/spec/lib/pwn/plugins/transparent_browser_spec.rb +8 -0
- data/spec/lib/pwn/sessions_spec.rb +24 -0
- data/third_party/pwn_rdoc.jsonl +41 -5
- data/tmp_pwn_critic_skills_flaw.txt +9 -0
- metadata +11 -8
|
@@ -93,9 +93,18 @@ module PWN
|
|
|
93
93
|
Prefer this order: use RECENT TURNS (current session already in
|
|
94
94
|
context), then `memory_recall`, then `session_recall`, then
|
|
95
95
|
`skills_recall`, then `pwn_eval` for PWN:: work, then `shell` for OS
|
|
96
|
-
commands. Save durable facts with `memory_remember`.
|
|
96
|
+
commands. Save durable facts with `memory_remember`. After a
|
|
97
|
+
resolved mistake or a durable procedure change, `skills_update`
|
|
98
|
+
folds that RL note into the matching skill.
|
|
97
99
|
TransparentBrowser: open once, reuse browser_obj, close once.
|
|
98
100
|
|
|
101
|
+
HOST LOAD
|
|
102
|
+
#{host_load_block}
|
|
103
|
+
On pwn_eval/shell timeout: reconstruct the ruby/command for the
|
|
104
|
+
same goal first. Do not first raise timeout. Only if construction
|
|
105
|
+
is sound, raise a conservative HOST LOAD timeout (clamped).
|
|
106
|
+
Record a mistake either way.
|
|
107
|
+
|
|
99
108
|
AUTONOMY
|
|
100
109
|
Multi-step goals must be finished in one Loop.run. Keep calling
|
|
101
110
|
CORE_TOOLS until the original request is done or truly blocked.
|
|
@@ -120,6 +129,9 @@ module PWN
|
|
|
120
129
|
World-knowledge questions (what color is X) may be answered as a
|
|
121
130
|
text final with no tools. Tools stay available; do not invent a
|
|
122
131
|
host-work plan for them.
|
|
132
|
+
Which skills are installed: call `skills_recall` with no query
|
|
133
|
+
(bundled catalog). Do not recite a mixed on-disk dump as the
|
|
134
|
+
catalog. Extra files under ~/.pwn/skills are not the catalog.
|
|
123
135
|
Do not treat process_sop_* or operator_pref_* memory about code
|
|
124
136
|
hygiene as the current user goal unless they asked to change code.
|
|
125
137
|
PROMPT
|
|
@@ -165,6 +177,21 @@ module PWN
|
|
|
165
177
|
RUBY_PLATFORM
|
|
166
178
|
end
|
|
167
179
|
|
|
180
|
+
private_class_method def self.host_load_block
|
|
181
|
+
snap = if defined?(ToolGuard) && ToolGuard.respond_to?(:host_load)
|
|
182
|
+
ToolGuard.host_load
|
|
183
|
+
else
|
|
184
|
+
{ ncpu: 1, load1: 0.0, mem_avail_mb: 0 }
|
|
185
|
+
end
|
|
186
|
+
eval_s = ToolGuard.deadline_s(kind: :eval) if defined?(ToolGuard)
|
|
187
|
+
shell_s = ToolGuard.deadline_s(kind: :shell) if defined?(ToolGuard)
|
|
188
|
+
load_line = "load1=#{snap[:load1]} ncpu=#{snap[:ncpu]} mem_avail_mb=#{snap[:mem_avail_mb]}"
|
|
189
|
+
"#{load_line} pwn_eval/shell timeout = conservative seconds for this host " \
|
|
190
|
+
"(defaults eval=#{eval_s || 20}s shell=#{shell_s || 30}s, clamped)."
|
|
191
|
+
rescue StandardError
|
|
192
|
+
'load unknown — pass a conservative timeout on pwn_eval/shell anyway.'
|
|
193
|
+
end
|
|
194
|
+
|
|
168
195
|
private_class_method def self.pwn_version
|
|
169
196
|
defined?(PWN::VERSION) ? PWN::VERSION : '?'
|
|
170
197
|
end
|
|
@@ -178,25 +205,7 @@ module PWN
|
|
|
178
205
|
sid = opts[:session_id]
|
|
179
206
|
pairs = (opts[:limit] || 2).to_i
|
|
180
207
|
pairs = 1 if pairs <= 0
|
|
181
|
-
|
|
182
|
-
req = opts[:request].to_s
|
|
183
|
-
if req.match?(
|
|
184
|
-
/
|
|
185
|
-
\b(
|
|
186
|
-
what\s+did\s+i\s+(just\s+)?say|
|
|
187
|
-
what\s+was\s+my\s+last|
|
|
188
|
-
how\s+did\s+you\s+respond|
|
|
189
|
-
what\s+did\s+you\s+(just\s+)?(?:say|answer|reply)|
|
|
190
|
-
what\s+(?:was|is)\s+your\s+(?:last|previous)|
|
|
191
|
-
remind\s+me\s+what|
|
|
192
|
-
previous\s+(request|message|turn)|
|
|
193
|
-
last\s+(thing|request|message)\s+i
|
|
194
|
-
)\b
|
|
195
|
-
/ix
|
|
196
|
-
)
|
|
197
|
-
pairs = [pairs, 2].max
|
|
198
|
-
end
|
|
199
|
-
dialog = PWN::Memory.recent_dialog(session_id: sid, pairs: pairs, max_chars: 1_200)
|
|
208
|
+
dialog = PWN::Memory.recent_dialog(session_id: sid, pairs: pairs, max_chars: 8_000)
|
|
200
209
|
return '' if dialog.nil? || dialog.empty?
|
|
201
210
|
|
|
202
211
|
lines = dialog.map do |t|
|
|
@@ -237,18 +246,27 @@ module PWN
|
|
|
237
246
|
private_class_method def self.skills_block
|
|
238
247
|
return '' unless defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) && !PWN::Skills.empty?
|
|
239
248
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
catalog_names = if defined?(PWN::Config) && PWN::Config.respond_to?(:default_skill_names)
|
|
250
|
+
Array(PWN::Config.default_skill_names)
|
|
251
|
+
else
|
|
252
|
+
[]
|
|
253
|
+
end
|
|
254
|
+
extra = 0
|
|
255
|
+
lines = []
|
|
256
|
+
PWN::Skills.each do |name, meta|
|
|
257
|
+
key = name.to_s
|
|
258
|
+
unless catalog_names.include?(key)
|
|
259
|
+
extra += 1
|
|
260
|
+
next
|
|
245
261
|
end
|
|
262
|
+
|
|
263
|
+
desc = meta.is_a?(Hash) ? meta[:description].to_s.strip : ''
|
|
264
|
+
desc = meta[:content].to_s.lines.reject { |l| l.strip.empty? || l.start_with?('---') }.first.to_s.strip if desc.empty? && meta.is_a?(Hash)
|
|
246
265
|
desc = desc[0, 100]
|
|
247
|
-
|
|
248
|
-
ref_tag = rc.positive? ? " [#{rc} refs]" : ''
|
|
249
|
-
" - #{name}: #{desc}#{ref_tag}"
|
|
266
|
+
lines << " - #{key}: #{desc}"
|
|
250
267
|
end
|
|
251
|
-
"
|
|
268
|
+
extra_line = extra.positive? ? " (#{extra} additional files under ~/.pwn/skills — call skills_recall to search; they are not this catalog)\n" : ''
|
|
269
|
+
"SKILLS CATALOG (bundled pwn-ai; call skills_recall with no query to list)\n#{lines.join("\n")}\n#{extra_line}\n"
|
|
252
270
|
rescue StandardError
|
|
253
271
|
''
|
|
254
272
|
end
|
|
@@ -41,7 +41,8 @@ module PWN
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
CORE_TOOLS = %w[memory_recall session_recall skills_recall pwn_eval shell
|
|
44
|
-
mistakes_record mistakes_resolve learning_note_outcome
|
|
44
|
+
mistakes_record mistakes_resolve learning_note_outcome
|
|
45
|
+
memory_remember skills_update].freeze
|
|
45
46
|
|
|
46
47
|
# Schema order = CORE_TOOLS. Current session is injected (RECENT TURNS).
|
|
47
48
|
# Then memory / prior sessions / skills, then pwn_eval before shell.
|
data/lib/pwn/ai/agent/reward.rb
CHANGED
|
@@ -534,6 +534,10 @@ module PWN
|
|
|
534
534
|
semantic = false
|
|
535
535
|
shape = :invalid_payload
|
|
536
536
|
err ||= 'invalid_payload'
|
|
537
|
+
elsif timeout_result?(err: err, raw: raw)
|
|
538
|
+
semantic = false
|
|
539
|
+
shape = :timeout
|
|
540
|
+
err ||= raw[/timeout after \d+s/i] || 'timeout'
|
|
537
541
|
else
|
|
538
542
|
semantic = ok && (exit_code.nil? || exit_code.zero? || benign)
|
|
539
543
|
end
|
|
@@ -541,6 +545,11 @@ module PWN
|
|
|
541
545
|
{ ok: ok, semantic_ok: semantic, exit: exit_code, err: err, benign: benign, shape: shape }
|
|
542
546
|
end
|
|
543
547
|
|
|
548
|
+
private_class_method def self.timeout_result?(opts = {})
|
|
549
|
+
blob = "#{opts[:err]} #{opts[:raw]}"
|
|
550
|
+
blob.match?(/timeout after \d+s/i) || blob.match?(/"error"\s*:\s*"timeout/i)
|
|
551
|
+
end
|
|
552
|
+
|
|
544
553
|
# 2.2 — coarse recoverable shape beside the fingerprint. Paths are
|
|
545
554
|
# normalised away for counting; shape stays for repair routing
|
|
546
555
|
# (enoent → install/check path; exit127 → missing binary; …).
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -186,12 +186,14 @@ module PWN
|
|
|
186
186
|
sys = build_persona_prompt(name: name, persona: persona,
|
|
187
187
|
swarm_id: sid, session_id: session_id)
|
|
188
188
|
|
|
189
|
+
empty_tools = opts[:text_only] == true || Array(persona[:toolsets]).empty?
|
|
189
190
|
Thread.current[:pwn_swarm_depth] = depth + 1
|
|
190
191
|
reply = with_persona_env(persona: persona) do
|
|
191
192
|
Loop.run(
|
|
192
193
|
request: opts[:request].to_s,
|
|
193
194
|
session_id: session_id,
|
|
194
|
-
enabled_toolsets: persona[:toolsets],
|
|
195
|
+
enabled_toolsets: empty_tools ? [] : persona[:toolsets],
|
|
196
|
+
core_only: empty_tools ? false : true,
|
|
195
197
|
system_role_content: sys,
|
|
196
198
|
on_tool: opts[:on_tool]
|
|
197
199
|
)
|
|
@@ -206,7 +206,8 @@ module PWN
|
|
|
206
206
|
end
|
|
207
207
|
when 'pwn_eval'
|
|
208
208
|
case sample
|
|
209
|
-
when
|
|
209
|
+
when /transparentbrowser|\.goto\b|browser_obj|dump_links|headless/ then 'browse'
|
|
210
|
+
when /\b(write|file\.|fileutils|binwrite)\b/ then 'mutate-ruby'
|
|
210
211
|
when /\b(load |require |const_get|method_list|instance_methods)\b/ then 'introspect-ruby'
|
|
211
212
|
else 'eval-ruby'
|
|
212
213
|
end
|
|
@@ -695,6 +696,17 @@ module PWN
|
|
|
695
696
|
opts[:goal].to_s.gsub(/\s+/, ' ').strip
|
|
696
697
|
end
|
|
697
698
|
|
|
699
|
+
private_class_method def self.browse_shaped?(opts = {})
|
|
700
|
+
Array(opts[:tools]).any? do |tool|
|
|
701
|
+
next unless tool.is_a?(Hash)
|
|
702
|
+
|
|
703
|
+
blob = "#{tool[:name]} #{tool[:args]}"
|
|
704
|
+
blob.match?(/transparentbrowser|\.goto\b|browser_obj|dump_links|headless/i)
|
|
705
|
+
end
|
|
706
|
+
rescue StandardError
|
|
707
|
+
false
|
|
708
|
+
end
|
|
709
|
+
|
|
698
710
|
# Infer a plain-English "why" from the tool mix.
|
|
699
711
|
# When a plan is active the goal is already on the plan line — keep why short
|
|
700
712
|
# so each about_to batch stays distinct and is not a near-duplicate of the plan.
|
|
@@ -702,7 +714,9 @@ module PWN
|
|
|
702
714
|
names = opts[:names]
|
|
703
715
|
caps = capabilities_for(names: names)
|
|
704
716
|
focus =
|
|
705
|
-
if
|
|
717
|
+
if browse_shaped?(tools: opts[:tools])
|
|
718
|
+
'navigate and collect from the live page'
|
|
719
|
+
elsif caps.any? { |c| c.include?('shell') || c.include?('Ruby') }
|
|
706
720
|
'gather evidence and apply code/host changes'
|
|
707
721
|
elsif caps.any? { |c| c.include?('extro') || c.include?('verify') }
|
|
708
722
|
'fact-check external state before acting'
|
|
@@ -796,6 +810,7 @@ module PWN
|
|
|
796
810
|
why = why_bit(
|
|
797
811
|
request: request,
|
|
798
812
|
names: names,
|
|
813
|
+
tools: tools,
|
|
799
814
|
with_goal: plan_bit.empty? && !plan_emitted
|
|
800
815
|
)
|
|
801
816
|
|
|
@@ -1345,7 +1360,7 @@ module PWN
|
|
|
1345
1360
|
/\b(implement\w*|fix|patch\w*|chang\w*|improv\w*|write|apply|wire|refactor\w*)\b/
|
|
1346
1361
|
)
|
|
1347
1362
|
return :discover if s.match?(
|
|
1348
|
-
/\b(locat\w*|find|read|inspect|recon\w*|understand|decompos\w*|map|identif\w*|gather|discover|enumerat\w*|scan|probe|determin\w*|root cause|where and why|track)\b/
|
|
1363
|
+
/\b(locat\w*|find|read|inspect|recon\w*|understand|decompos\w*|map|identif\w*|gather|discover|enumerat\w*|scan|probe|determin\w*|root cause|where and why|track|navigat\w*|browse|goto)\b/
|
|
1349
1364
|
)
|
|
1350
1365
|
|
|
1351
1366
|
:generic
|
|
@@ -1357,7 +1372,7 @@ module PWN
|
|
|
1357
1372
|
s = opts[:intent].to_s.downcase
|
|
1358
1373
|
return :verify if s.match?(/test|rubocop|rake|rspec|offenses|failures/)
|
|
1359
1374
|
return :mutate if s.match?(/edit|mutate|write|patch|sed\s+-i|file.write|refactor/)
|
|
1360
|
-
return :discover if s.match?(/search|read|recon|extro|sessions|memory|find|list|scan|inspect|locat/)
|
|
1375
|
+
return :discover if s.match?(/search|read|recon|extro|sessions|memory|find|list|scan|inspect|locat|browse|goto|navigat/)
|
|
1361
1376
|
|
|
1362
1377
|
:generic
|
|
1363
1378
|
rescue StandardError
|
|
@@ -1437,12 +1452,14 @@ module PWN
|
|
|
1437
1452
|
when :mutate, :verify
|
|
1438
1453
|
item_covered?(item: opts[:item], blob: joined)
|
|
1439
1454
|
when :discover, :generic, :present
|
|
1455
|
+
browse_hit = joined.match?(/effect["\s:=]+browse|transparentbrowser|\.goto\b|\bbrowse\b/i)
|
|
1456
|
+
min_tools = browse_hit ? 1 : DISCOVER_MIN_TOOLS
|
|
1440
1457
|
on_task = opts[:state].is_a?(Hash) ? opts[:state][:tools_on_task].to_i : 0
|
|
1441
|
-
return false if on_task <
|
|
1458
|
+
return false if on_task < min_tools
|
|
1442
1459
|
return false unless item_covered?(item: opts[:item], blob: joined)
|
|
1443
1460
|
|
|
1444
1461
|
intent_s = (Array(opts[:intents]) + Array(opts[:names])).join(' ')
|
|
1445
|
-
task_intent_match?(item: opts[:item], intent: intent_s) || phase == :present
|
|
1462
|
+
task_intent_match?(item: opts[:item], intent: intent_s) || phase == :present || browse_hit
|
|
1446
1463
|
else
|
|
1447
1464
|
false
|
|
1448
1465
|
end
|
|
@@ -94,6 +94,115 @@ module PWN
|
|
|
94
94
|
}
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
public_class_method def self.host_load(opts = {})
|
|
98
|
+
return { ncpu: 1, load1: 0.0, mem_avail_mb: 0 } unless opts.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
ncpu = File.readable?('/proc/cpuinfo') ? File.read('/proc/cpuinfo').scan(/^processor/).size : 0
|
|
101
|
+
ncpu = 1 if ncpu < 1
|
|
102
|
+
load1 = 0.0
|
|
103
|
+
load1 = File.read('/proc/loadavg').to_s.split[0].to_f if File.readable?('/proc/loadavg')
|
|
104
|
+
avail = 0
|
|
105
|
+
if File.readable?('/proc/meminfo')
|
|
106
|
+
File.foreach('/proc/meminfo') do |ln|
|
|
107
|
+
next unless ln.start_with?('MemAvailable:')
|
|
108
|
+
|
|
109
|
+
avail = ln.split[1].to_i / 1024
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
{ ncpu: ncpu, load1: load1, mem_avail_mb: avail }
|
|
114
|
+
rescue StandardError
|
|
115
|
+
{ ncpu: 1, load1: 0.0, mem_avail_mb: 0 }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Conservative wall-clock seconds for shell / pwn_eval.
|
|
119
|
+
# Explicit model estimate is honored then clamped. Omit → host-derived
|
|
120
|
+
# default from loadavg / ncpu / MemAvailable (loaded hosts get a bit
|
|
121
|
+
# more time, still capped).
|
|
122
|
+
public_class_method def self.deadline_s(opts = {})
|
|
123
|
+
kind = opts[:kind].to_s.to_sym
|
|
124
|
+
max = kind == :shell ? 180 : 90
|
|
125
|
+
asked = opts[:timeout] || opts[:timeout_s]
|
|
126
|
+
asked_i = asked.to_i
|
|
127
|
+
if asked_i.positive?
|
|
128
|
+
lo = 1
|
|
129
|
+
hi = max
|
|
130
|
+
return asked_i.clamp(lo, hi)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
snap = host_load
|
|
134
|
+
ncpu = [snap[:ncpu].to_i, 1].max
|
|
135
|
+
load1 = snap[:load1].to_f
|
|
136
|
+
mem = snap[:mem_avail_mb].to_i
|
|
137
|
+
base = kind == :shell ? 30 : 20
|
|
138
|
+
base += 15 if load1 > ncpu
|
|
139
|
+
base += 10 if load1 > (ncpu * 1.5)
|
|
140
|
+
base += 10 if mem.positive? && mem < 512
|
|
141
|
+
base.clamp(8, max)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Two-scenario timeout policy (loop-law, not a skill):
|
|
145
|
+
# 1. First timeout → reconstruct the ruby/command for the same goal.
|
|
146
|
+
# Do not first raise timeout.
|
|
147
|
+
# 2. A later timeout of the same tool → deadline was too short; raise
|
|
148
|
+
# a conservative HOST LOAD timeout (still clamped).
|
|
149
|
+
public_class_method def self.timeout_lesson(opts = {})
|
|
150
|
+
return { scenario: :construction, error: '', hint: '' } unless opts.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
tool = opts[:tool].to_s
|
|
153
|
+
timeout = opts[:timeout].to_i
|
|
154
|
+
prior = timeout_prior_count(tool: tool)
|
|
155
|
+
if prior.positive?
|
|
156
|
+
{
|
|
157
|
+
scenario: :deadline,
|
|
158
|
+
error: "#{tool} timeout: deadline too short; raise conservative HOST LOAD timeout",
|
|
159
|
+
hint: "Scenario 2: the #{tool} payload already timed out once. " \
|
|
160
|
+
"This timeout (#{timeout}s) was too short. Raise a conservative " \
|
|
161
|
+
'timeout from HOST LOAD (clamped). Record this so it does not recur. ' \
|
|
162
|
+
'Do not retry the identical payload at the same deadline.'
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
{
|
|
166
|
+
scenario: :construction,
|
|
167
|
+
error: "#{tool} timeout: reconstruct payload to same goal before raising timeout",
|
|
168
|
+
hint: "Scenario 1 (try first): the #{tool} ruby/command was constructed " \
|
|
169
|
+
'improperly. Generate it differently to achieve the same goal. ' \
|
|
170
|
+
'Do not first raise timeout. Record this so it does not recur.'
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
public_class_method def self.timeout_result(opts = {})
|
|
176
|
+
return { stdout: '', stderr: '', exit: nil, error: 'timeout', scenario: :construction, hint: '', shell: shell_name } unless opts.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
lesson = timeout_lesson(
|
|
179
|
+
tool: opts[:tool],
|
|
180
|
+
payload: opts[:payload],
|
|
181
|
+
timeout: opts[:timeout]
|
|
182
|
+
)
|
|
183
|
+
{
|
|
184
|
+
stdout: opts[:stdout].to_s,
|
|
185
|
+
stderr: opts[:stderr].to_s,
|
|
186
|
+
exit: nil,
|
|
187
|
+
error: "timeout after #{opts[:timeout].to_i}s",
|
|
188
|
+
scenario: lesson[:scenario],
|
|
189
|
+
hint: lesson[:hint],
|
|
190
|
+
shell: opts[:shell] || shell_name
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
public_class_method def self.timeout_prior_count(opts = {})
|
|
195
|
+
return 0 unless opts.is_a?(Hash)
|
|
196
|
+
return 0 unless defined?(PWN::AI::Agent::Mistakes)
|
|
197
|
+
|
|
198
|
+
tool = opts[:tool].to_s
|
|
199
|
+
PWN::AI::Agent::Mistakes.for_tool(tool: tool, unresolved_only: true).count do |m|
|
|
200
|
+
m[:shape].to_s == 'timeout' || m[:error].to_s.match?(/timeout/)
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError
|
|
203
|
+
0
|
|
204
|
+
end
|
|
205
|
+
|
|
97
206
|
public_class_method def self.authors
|
|
98
207
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
99
208
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'stringio'
|
|
4
|
+
require 'timeout'
|
|
4
5
|
require 'pwn/ai/agent/registry'
|
|
5
6
|
require 'pwn/ai/agent/tool_guard'
|
|
6
7
|
|
|
@@ -16,12 +17,23 @@ PWN::AI::Agent::Registry.register(
|
|
|
16
17
|
description: 'Evaluate Ruby in the live pwn REPL process with the full ' \
|
|
17
18
|
'PWN:: namespace available (PWN::Plugins::NmapIt, ' \
|
|
18
19
|
'PWN::Plugins::TransparentBrowser, PWN::Plugins::BurpSuite, ' \
|
|
19
|
-
'PWN::SAST, PWN::Reports, PWN::AI::Agent::*, etc.).
|
|
20
|
-
'
|
|
20
|
+
'PWN::SAST, PWN::Reports, PWN::AI::Agent::*, etc.). Locals ' \
|
|
21
|
+
'persist across calls in this process — open a browser once ' \
|
|
22
|
+
'(browser_obj = TransparentBrowser.open(...); browser = ' \
|
|
23
|
+
'browser_obj[:browser]), then navigate via that same ' \
|
|
24
|
+
'browser. Do not open a second browser. Close once with ' \
|
|
25
|
+
'Close once with TransparentBrowser.close(browser_obj: browser_obj). ' \
|
|
26
|
+
'Pass timeout as a conservative integer seconds estimate given HOST ' \
|
|
27
|
+
'LOAD (loadavg, ncpu, mem). Omit for a host-derived default (clamped). ' \
|
|
28
|
+
'Returns captured stdout plus the inspected value of the last expression.',
|
|
21
29
|
parameters: {
|
|
22
30
|
type: 'object',
|
|
23
31
|
properties: {
|
|
24
|
-
code: { type: 'string', description: 'Ruby source to evaluate.' }
|
|
32
|
+
code: { type: 'string', description: 'Ruby source to evaluate.' },
|
|
33
|
+
timeout: {
|
|
34
|
+
type: 'integer',
|
|
35
|
+
description: 'Conservative seconds this eval should take given HOST LOAD. Omit for host-derived default. Clamped 1..90.'
|
|
36
|
+
}
|
|
25
37
|
},
|
|
26
38
|
required: %w[code]
|
|
27
39
|
}
|
|
@@ -49,45 +61,57 @@ PWN::AI::Agent::Registry.register(
|
|
|
49
61
|
old_stdout = $stdout
|
|
50
62
|
buf = StringIO.new
|
|
51
63
|
$stdout = buf
|
|
64
|
+
timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :eval)
|
|
52
65
|
begin
|
|
53
66
|
# rubocop:disable Security/Eval
|
|
54
|
-
# rubocop:disable Style/DocumentDynamicEvalDefinition
|
|
55
67
|
# INTENTIONAL: this IS the pwn-ai → PWN bridge
|
|
56
68
|
# As YTCracker says, "It ain't a bug, it's a featcha."
|
|
57
69
|
# https://www.youtube.com/watch?v=2nALqqSqdDw
|
|
58
70
|
#
|
|
59
71
|
# File label '(pwn_eval)' (not __FILE__) so SyntaxError / NameError
|
|
60
|
-
# messages cite the *payload* line, not ruby_eval.rb
|
|
61
|
-
# (e.g. "ruby_eval:49results = probes.map do |R|").
|
|
72
|
+
# messages cite the *payload* line, not ruby_eval.rb glued to source.
|
|
62
73
|
# Rescue ScriptError (SyntaxError, LoadError, NotImplementedError) as
|
|
63
74
|
# well as StandardError — Dispatch only catches StandardError, so an
|
|
64
75
|
# unrescued SyntaxError previously escaped the tool loop. Returning a
|
|
65
76
|
# structured error lets the model self-heal (fix code and retry).
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
# Evaluate in TOPLEVEL_BINDING so locals (browser_obj, browser)
|
|
78
|
+
# persist across pwn_eval calls. Wrapping in proc { } made each
|
|
79
|
+
# call a fresh scope — the next snippet hit NameError and opened
|
|
80
|
+
# another Chrome.
|
|
81
|
+
val = Timeout.timeout(timeout) do
|
|
82
|
+
eval(
|
|
83
|
+
code,
|
|
84
|
+
TOPLEVEL_BINDING,
|
|
85
|
+
'(pwn_eval)',
|
|
86
|
+
1
|
|
87
|
+
)
|
|
88
|
+
end
|
|
77
89
|
# rubocop:enable Security/Eval
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
{ stdout: buf.string, value: val.inspect, timeout: timeout }
|
|
91
|
+
rescue Timeout::Error
|
|
92
|
+
lesson = PWN::AI::Agent::ToolGuard.timeout_lesson(
|
|
93
|
+
tool: 'pwn_eval',
|
|
94
|
+
payload: code,
|
|
95
|
+
timeout: timeout
|
|
96
|
+
)
|
|
97
|
+
{
|
|
98
|
+
stdout: buf.string,
|
|
99
|
+
error: "timeout after #{timeout}s",
|
|
100
|
+
scenario: lesson[:scenario],
|
|
101
|
+
hint: lesson[:hint]
|
|
102
|
+
}
|
|
80
103
|
rescue ScriptError, StandardError => e
|
|
81
104
|
{
|
|
82
105
|
stdout: buf.string,
|
|
83
106
|
error: "#{e.class}: #{e.message}",
|
|
84
107
|
backtrace: Array(e.backtrace).first(5),
|
|
85
|
-
# Hint common payload footguns the model keeps emitting
|
|
86
108
|
hint: (
|
|
87
109
|
if e.is_a?(SyntaxError) && e.message.match?(/formal argument cannot be a constant/)
|
|
88
110
|
'Block parameters must be local variables (lowercase), e.g. |r| not |R|. Also close every do/end.'
|
|
89
111
|
elsif e.is_a?(SyntaxError)
|
|
90
112
|
'Payload failed to parse. Check matching do/end, braces, and that no line-number prefix was glued onto the code.'
|
|
113
|
+
elsif e.is_a?(NameError) && e.message.match?(/undefined local variable or method '(browser|browser_obj)'/)
|
|
114
|
+
'Reuse the existing browser_obj / browser from the prior pwn_eval. Do not call TransparentBrowser.open again.'
|
|
91
115
|
end
|
|
92
116
|
)
|
|
93
117
|
}
|
|
@@ -21,12 +21,17 @@ PWN::AI::Agent::Registry.register(
|
|
|
21
21
|
name: 'shell',
|
|
22
22
|
description: 'Execute a shell command on the local pwn host and return ' \
|
|
23
23
|
'stdout/stderr/exit code. Use for OS-level work: nmap, curl, ' \
|
|
24
|
-
'ls, git, file inspection, anything not in the PWN:: namespace.'
|
|
24
|
+
'ls, git, file inspection, anything not in the PWN:: namespace. ' \
|
|
25
|
+
'Pass timeout as a conservative integer seconds estimate given ' \
|
|
26
|
+
'HOST LOAD (loadavg, ncpu, mem). Omit for a host-derived default (clamped).',
|
|
25
27
|
parameters: {
|
|
26
28
|
type: 'object',
|
|
27
29
|
properties: {
|
|
28
30
|
command: { type: 'string', description: 'The exact shell command to run.' },
|
|
29
|
-
timeout: {
|
|
31
|
+
timeout: {
|
|
32
|
+
type: 'integer',
|
|
33
|
+
description: 'Conservative seconds this command should take given HOST LOAD. Omit for host-derived default. Clamped 1..180.'
|
|
34
|
+
}
|
|
30
35
|
},
|
|
31
36
|
required: %w[command]
|
|
32
37
|
}
|
|
@@ -47,7 +52,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
47
52
|
.gsub(/\\\r?\n/, ' ')
|
|
48
53
|
.gsub(/\\+\s*\z/, '')
|
|
49
54
|
.strip
|
|
50
|
-
timeout = (args[:timeout]
|
|
55
|
+
timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :shell)
|
|
51
56
|
if cmd.empty? || PWN::AI::Agent::ToolGuard.placeholder?(text: cmd)
|
|
52
57
|
return PWN::AI::Agent::ToolGuard.invalid_payload(
|
|
53
58
|
hint: 'command is required (string). Do not send ..., {...}, {…}, or empty. ' \
|
|
@@ -158,7 +163,15 @@ PWN::AI::Agent::Registry.register(
|
|
|
158
163
|
end
|
|
159
164
|
end
|
|
160
165
|
|
|
161
|
-
|
|
166
|
+
if timed_out
|
|
167
|
+
return PWN::AI::Agent::ToolGuard.timeout_result(
|
|
168
|
+
tool: 'shell',
|
|
169
|
+
payload: cmd,
|
|
170
|
+
stdout: stdout,
|
|
171
|
+
stderr: stderr,
|
|
172
|
+
timeout: timeout
|
|
173
|
+
)
|
|
174
|
+
end
|
|
162
175
|
|
|
163
176
|
{ stdout: stdout, stderr: stderr, exit: exitstatus, shell: PWN::AI::Agent::ToolGuard.shell_name }
|
|
164
177
|
}
|
|
@@ -30,8 +30,9 @@ PWN::AI::Agent::Registry.register(
|
|
|
30
30
|
description: 'Search installed pwn-ai skills (name, description, body, ' \
|
|
31
31
|
'references) for how to do similar work. Call before ' \
|
|
32
32
|
'pwn_eval / shell when a reusable procedure may exist. ' \
|
|
33
|
-
'
|
|
34
|
-
'
|
|
33
|
+
'Empty query returns the bundled pwn-ai catalog only — not ' \
|
|
34
|
+
'a mixed ~/.pwn/skills dump. A keyword query searches the ' \
|
|
35
|
+
'live index.',
|
|
35
36
|
parameters: {
|
|
36
37
|
type: 'object',
|
|
37
38
|
properties: {
|
|
@@ -50,6 +51,30 @@ PWN::AI::Agent::Registry.register(
|
|
|
50
51
|
limit = (args[:limit] || 6).to_i
|
|
51
52
|
limit = 6 if limit <= 0
|
|
52
53
|
trunc = 400
|
|
54
|
+
catalog_names = if defined?(PWN::Config) && PWN::Config.respond_to?(:default_skill_names)
|
|
55
|
+
Array(PWN::Config.default_skill_names)
|
|
56
|
+
else
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
if query.empty? && tokens.empty?
|
|
60
|
+
catalog = []
|
|
61
|
+
other = 0
|
|
62
|
+
PWN::Skills.each do |name, meta|
|
|
63
|
+
next unless meta.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
key = name.to_s
|
|
66
|
+
unless catalog_names.include?(key)
|
|
67
|
+
other += 1
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
catalog << {
|
|
72
|
+
name: key,
|
|
73
|
+
description: meta[:description].to_s
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
return { catalog: catalog, other_installed: other }
|
|
77
|
+
end
|
|
53
78
|
hits = []
|
|
54
79
|
PWN::Skills.each do |name, meta|
|
|
55
80
|
next unless meta.is_a?(Hash)
|
|
@@ -78,6 +103,42 @@ PWN::AI::Agent::Registry.register(
|
|
|
78
103
|
}
|
|
79
104
|
)
|
|
80
105
|
|
|
106
|
+
PWN::AI::Agent::Registry.register(
|
|
107
|
+
name: 'skills_update',
|
|
108
|
+
toolset: 'skills',
|
|
109
|
+
schema: {
|
|
110
|
+
name: 'skills_update',
|
|
111
|
+
description: 'Fold RL feedback (resolved mistakes, structured fixes, ' \
|
|
112
|
+
'or an explicit lesson) into an existing skill SOP. Does ' \
|
|
113
|
+
'not create skills and does not write loop-law. Call after ' \
|
|
114
|
+
'mistakes_resolve / learning_note_outcome when a durable ' \
|
|
115
|
+
'procedure should change. Omitting name ranks installed ' \
|
|
116
|
+
'skills from query/request.',
|
|
117
|
+
parameters: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
name: { type: 'string', description: 'Existing skill to update. Ranked from query when omitted.' },
|
|
121
|
+
query: { type: 'string', description: 'Keywords to pick the skill when name is omitted.' },
|
|
122
|
+
lesson: { type: 'string', description: 'Optional explicit note to append under ## RL feedback.' },
|
|
123
|
+
signature: { type: 'string', description: 'Optional Mistakes signature to fold.' },
|
|
124
|
+
dry_run: { type: 'boolean', description: 'When true, do not write the skill file.' }
|
|
125
|
+
},
|
|
126
|
+
required: []
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
check: -> { defined?(PWN::AI::Agent::Learning) && PWN::AI::Agent::Learning.respond_to?(:update_skill) },
|
|
130
|
+
handler: lambda { |args|
|
|
131
|
+
PWN::AI::Agent::Learning.update_skill(
|
|
132
|
+
name: args[:name],
|
|
133
|
+
query: args[:query],
|
|
134
|
+
lesson: args[:lesson],
|
|
135
|
+
signature: args[:signature],
|
|
136
|
+
request: args[:query],
|
|
137
|
+
dry_run: args[:dry_run]
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
81
142
|
PWN::AI::Agent::Registry.register(
|
|
82
143
|
name: 'skill_list',
|
|
83
144
|
toolset: 'skills',
|
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -409,8 +409,8 @@ module PWN
|
|
|
409
409
|
http_body = opts[:http_body]
|
|
410
410
|
http_body ||= {}
|
|
411
411
|
|
|
412
|
-
timeout = opts
|
|
413
|
-
|
|
412
|
+
timeout = PWN::AI::HttpRetry.timeout_s(opts)
|
|
413
|
+
max_attempts = PWN::AI::HttpRetry.max_attempts(opts)
|
|
414
414
|
|
|
415
415
|
spinner = opts[:spinner] || false
|
|
416
416
|
|
|
@@ -460,11 +460,31 @@ module PWN
|
|
|
460
460
|
|
|
461
461
|
response.body
|
|
462
462
|
rescue RestClient::TooManyRequests => e
|
|
463
|
-
retry_after = e.response.headers[:retry_after]&.to_i || (0.5 * (retry_count + 1))
|
|
464
|
-
sleep(retry_after + rand(0.3..5.0))
|
|
465
463
|
retry_count += 1
|
|
466
|
-
|
|
464
|
+
if retry_count >= max_attempts
|
|
465
|
+
unless opts[:quiet]
|
|
466
|
+
PWN::AI::HttpRetry.report_event(
|
|
467
|
+
label: 'anthropic', which_self: self, quiet: opts[:quiet],
|
|
468
|
+
http_method: http_method, rest_call: rest_call,
|
|
469
|
+
extra: '429 retries exhausted', error: e
|
|
470
|
+
)
|
|
471
|
+
end
|
|
472
|
+
raise "Anthropic API Error: #{e.message}: #{e.response}"
|
|
473
|
+
end
|
|
474
|
+
sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..5.0))
|
|
467
475
|
retry
|
|
476
|
+
rescue RestClient::Exceptions::Timeout => e
|
|
477
|
+
retry_count += 1
|
|
478
|
+
unless opts[:quiet]
|
|
479
|
+
PWN::AI::HttpRetry.report_event(
|
|
480
|
+
label: 'anthropic', which_self: self, quiet: opts[:quiet],
|
|
481
|
+
http_method: http_method, rest_call: rest_call,
|
|
482
|
+
extra: "timeout=#{timeout}s attempt=#{retry_count}/#{max_attempts}", error: e
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
retry if retry_count < max_attempts
|
|
486
|
+
|
|
487
|
+
raise "Anthropic API Error: #{e.class}: #{e.message}"
|
|
468
488
|
rescue RestClient::ExceptionWithResponse => e
|
|
469
489
|
raise "Anthropic API Error: #{e.message}: #{e.response}"
|
|
470
490
|
rescue StandardError => e
|