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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.ruby-version +1 -1
  4. data/Gemfile +3 -3
  5. data/README.md +3 -4
  6. data/documentation/Agent-Tool-Registry.md +2 -2
  7. data/documentation/Configuration.md +5 -4
  8. data/documentation/General-PWN-Usage.md +1 -1
  9. data/documentation/Home.md +1 -1
  10. data/documentation/How-PWN-Works.md +1 -1
  11. data/documentation/Mistakes.md +1 -1
  12. data/documentation/Reinforcement-Learning.md +4 -6
  13. data/documentation/Troubleshooting.md +1 -1
  14. data/documentation/pwn-REPL.md +1 -1
  15. data/documentation/pwn-ai-Agent.md +2 -3
  16. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  17. data/lib/pwn/ai/agent/dispatch.rb +1 -1
  18. data/lib/pwn/ai/agent/learning.rb +150 -32
  19. data/lib/pwn/ai/agent/loop.rb +291 -129
  20. data/lib/pwn/ai/agent/prompt_builder.rb +47 -29
  21. data/lib/pwn/ai/agent/registry.rb +2 -1
  22. data/lib/pwn/ai/agent/reward.rb +9 -0
  23. data/lib/pwn/ai/agent/swarm.rb +3 -1
  24. data/lib/pwn/ai/agent/task_summarizer.rb +23 -6
  25. data/lib/pwn/ai/agent/tool_guard.rb +109 -0
  26. data/lib/pwn/ai/agent/tools/ruby_eval.rb +44 -20
  27. data/lib/pwn/ai/agent/tools/shell.rb +17 -4
  28. data/lib/pwn/ai/agent/tools/skills.rb +63 -2
  29. data/lib/pwn/ai/anthropic.rb +25 -5
  30. data/lib/pwn/ai/gemini.rb +25 -5
  31. data/lib/pwn/ai/grok.rb +30 -13
  32. data/lib/pwn/ai/http_retry.rb +78 -0
  33. data/lib/pwn/ai/ollama.rb +25 -5
  34. data/lib/pwn/ai/open_ai.rb +26 -13
  35. data/lib/pwn/ai/open_web_ui.rb +25 -5
  36. data/lib/pwn/ai.rb +1 -0
  37. data/lib/pwn/config.rb +1 -1
  38. data/lib/pwn/plugins/log.rb +183 -41
  39. data/lib/pwn/plugins/repl.rb +50 -15
  40. data/lib/pwn/plugins/transparent_browser.rb +16 -2
  41. data/lib/pwn/sessions.rb +50 -1
  42. data/lib/pwn/version.rb +1 -1
  43. data/spec/integration/reinforced_feedback_loop_spec.rb +9 -11
  44. data/spec/lib/pwn/ai/agent/loop_spec.rb +180 -20
  45. data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +3 -0
  46. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  47. data/spec/lib/pwn/ai/agent/reward_spec.rb +13 -0
  48. data/spec/lib/pwn/ai/agent/swarm_spec.rb +6 -0
  49. data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +36 -0
  50. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +46 -0
  51. data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +24 -0
  52. data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -0
  53. data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +29 -0
  54. data/spec/lib/pwn/ai/grok_spec.rb +22 -0
  55. data/spec/lib/pwn/ai/http_retry_spec.rb +55 -0
  56. data/spec/lib/pwn/plugins/log_spec.rb +170 -19
  57. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +14 -4
  58. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +8 -0
  59. data/spec/lib/pwn/sessions_spec.rb +24 -0
  60. data/third_party/pwn_rdoc.jsonl +41 -5
  61. data/tmp_pwn_critic_skills_flaw.txt +9 -0
  62. 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
- # On pure-recall / vague cues, prefer a slightly richer window if budget allows.
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
- lines = PWN::Skills.map do |name, meta|
241
- desc = meta[:description].to_s.strip
242
- if desc.empty?
243
- # legacy / stubbed entry without a parsed description — fall back
244
- desc = meta[:content].to_s.lines.reject { |l| l.strip.empty? || l.start_with?('---') }.first.to_s.strip
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
- rc = Array(meta[:references]).length
248
- ref_tag = rc.positive? ? " [#{rc} refs]" : ''
249
- " - #{name}: #{desc}#{ref_tag}"
266
+ lines << " - #{key}: #{desc}"
250
267
  end
251
- "SKILLS (call skill_view to read full body)\n#{lines.join("\n")}\n\n"
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 memory_remember].freeze
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.
@@ -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; …).
@@ -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 /\b(write|file\.|fileutils|open\(|binwrite|puts )\b/ then 'mutate-ruby'
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 caps.any? { |c| c.include?('shell') || c.include?('Ruby') }
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 < DISCOVER_MIN_TOOLS
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.). Returns ' \
20
- 'captured stdout plus the inspected value of the last expression.',
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:49 glued to source
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
- # rubocop:disable Style/EvalWithLocation -- intentional file label
67
- # '(pwn_eval)' so SyntaxError/NameError messages cite the payload, not
68
- # this library line (specs + model self-heal depend on that string).
69
- proc = eval(
70
- "proc { #{code}\n}",
71
- TOPLEVEL_BINDING,
72
- '(pwn_eval)',
73
- 1
74
- )
75
- # rubocop:enable Style/EvalWithLocation
76
- val = proc.call
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
- # rubocop:enable Style/DocumentDynamicEvalDefinition
79
- { stdout: buf.string, value: val.inspect }
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: { type: 'integer', description: 'Seconds before the command is killed.', default: 120 }
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] || 120).to_i
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
- return { stdout: stdout, stderr: stderr, exit: nil, error: "timeout after #{timeout}s", shell: PWN::AI::Agent::ToolGuard.shell_name } if timed_out
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
- 'The SKILLS index is already in the prompt — this loads ' \
34
- 'matching full methods.',
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',
@@ -409,8 +409,8 @@ module PWN
409
409
  http_body = opts[:http_body]
410
410
  http_body ||= {}
411
411
 
412
- timeout = opts[:timeout]
413
- timeout ||= 900
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