pwn 0.5.720 → 0.5.722

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
  3. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +4 -2
  4. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +2 -0
  5. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
  6. data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
  7. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  8. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
  10. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  14. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  15. data/lib/pwn/ai/agent/dispatch.rb +15 -5
  16. data/lib/pwn/ai/agent/learning.rb +52 -1
  17. data/lib/pwn/ai/agent/loop.rb +74 -26
  18. data/lib/pwn/ai/agent/metrics.rb +53 -1
  19. data/lib/pwn/ai/agent/mistakes.rb +67 -1
  20. data/lib/pwn/ai/agent/registry.rb +28 -0
  21. data/lib/pwn/ai/agent/reward.rb +15 -10
  22. data/lib/pwn/ai/agent/tool_guard.rb +54 -9
  23. data/lib/pwn/ai/agent/tools/artifacts.rb +8 -3
  24. data/lib/pwn/ai/agent/turn_finalizer.rb +20 -1
  25. data/lib/pwn/plugins/artifact_registry.rb +54 -8
  26. data/lib/pwn/plugins/binary_parser.rb +4 -1
  27. data/lib/pwn/plugins/exploit_dev.rb +48 -0
  28. data/lib/pwn/plugins/findings.rb +9 -1
  29. data/lib/pwn/plugins/gdb.rb +17 -0
  30. data/lib/pwn/plugins/ghidra.rb +20 -2
  31. data/lib/pwn/plugins/jobs.rb +19 -0
  32. data/lib/pwn/plugins/preflight_checker.rb +23 -0
  33. data/lib/pwn/plugins/process_tube.rb +31 -1
  34. data/lib/pwn/plugins/repl.rb +6 -1
  35. data/lib/pwn/sessions.rb +4 -0
  36. data/lib/pwn/version.rb +1 -1
  37. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
  38. data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
  39. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
  40. data/spec/lib/pwn/ai/agent/reward_spec.rb +13 -1
  41. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
  42. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +9 -0
  43. data/spec/lib/pwn/plugins/findings_spec.rb +4 -4
  44. data/third_party/pwn_rdoc.jsonl +20 -0
  45. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0dd0d654d48c9ead7f9a92893f88443e1cfd7cd9a867c53cb2ffc25cbb60fe27
4
- data.tar.gz: 3960e8501cbd0139184cd0981569a28a4f9b37e47ddddeb5dab001b2d0eefb1b
3
+ metadata.gz: 31f54d1cd6a7bfbc4cef3bcc989650930b1b31bbf2bbc47654b20d6f20158775
4
+ data.tar.gz: 50bfda31eecd4b99d17324a8c4c548f14b8df10561c183f27bc9e7157c8f88bf
5
5
  SHA512:
6
- metadata.gz: defca7be52f6960bbaa318f5e40e4927cafa8a1e679ce27dd8e74b73feae4b89a9766b725a5731b2e2fa5a8e93c58a2fa3622f0c37df6b0355bf6b12e9affbae
7
- data.tar.gz: 29de8ee9497ec7004c4752ddaf3fc359016c6007368e051e891e191ad18bf069a3a44d32aa73ed480dc60cb30bba46551efa2f636009b8d50752e0101917900b
6
+ metadata.gz: 3a43118f36da1f9712dbfb908b5f0c9d4650d3bfde63239240d7c9cefcc50d29d78468e14e9cd9bf44f9fdba5df971b48659b348619dc79a7e17667028930025
7
+ data.tar.gz: 867d72ec34f85caaf18fa5abeb38b466d5663c1361becf17cb4af87115dd0d45411527b0f5616dd54874c241544416fb0b9427f16476248ed38a864b3951daef
@@ -34,6 +34,8 @@ PWN::AI::Agent::Learning.note_outcome(opts)
34
34
  ## Public methods
35
35
 
36
36
  - `note_outcome`
37
+ - `consistency_check`
38
+ - `disputed_save`
37
39
  - `outcomes`
38
40
  - `stats`
39
41
  - `to_context`
@@ -28,11 +28,12 @@ Class methods take `(opts = {})` and read `opts`.
28
28
 
29
29
  ```ruby
30
30
  PWN::AI::Agent::Loop.help
31
- PWN::AI::Agent::Loop.debug_on(opts)
31
+ PWN::AI::Agent::Loop.evidence_satisfied(opts)
32
32
  ```
33
33
 
34
34
  ## Public methods
35
35
 
36
+ - `evidence_satisfied`
36
37
  - `debug_on`
37
38
  - `catalog_lookup`
38
39
  - `world_knowledge`
@@ -46,6 +47,7 @@ PWN::AI::Agent::Loop.debug_on(opts)
46
47
  - `help`
47
48
  - `catalog_lookup?`
48
49
  - `debug_on?`
50
+ - `evidence_satisfied?`
49
51
  - `needs_host_work?`
50
52
  - `world_knowledge?`
51
53
 
@@ -59,5 +61,5 @@ PWN::AI::Agent::Loop.debug_on(opts)
59
61
 
60
62
  ## Verification
61
63
 
62
- `PWN::AI::Agent::Loop.respond_to?(:debug_on)` after the
64
+ `PWN::AI::Agent::Loop.respond_to?(:evidence_satisfied)` after the
63
65
  module is loaded. Read the source for parameter names.
@@ -36,7 +36,9 @@ PWN::AI::Agent::Metrics.load(opts)
36
36
  - `load`
37
37
  - `save`
38
38
  - `record`
39
+ - `append_jsonl`
39
40
  - `summary`
41
+ - `snapshot`
40
42
  - `to_context`
41
43
  - `proxy_trust`
42
44
  - `ucb`
@@ -37,6 +37,7 @@ PWN::AI::Agent::Mistakes.load(opts)
37
37
  - `save`
38
38
  - `signature`
39
39
  - `error_class`
40
+ - `family`
40
41
  - `find`
41
42
  - `for_tool`
42
43
  - `record`
@@ -48,6 +49,7 @@ PWN::AI::Agent::Mistakes.load(opts)
48
49
  - `operator_inbox`
49
50
  - `to_context`
50
51
  - `correction_hint`
52
+ - `note_hint_outcome`
51
53
  - `correction`
52
54
  - `check_user_correction`
53
55
  - `lean`
@@ -42,8 +42,11 @@ PWN::AI::Agent::Registry.register(opts)
42
42
  - `apply_preference`
43
43
  - `rank`
44
44
  - `discover`
45
+ - `eager_load`
46
+ - `selftest`
45
47
  - `authors`
46
48
  - `help`
49
+ - `eager_load!`
47
50
 
48
51
  ## Source
49
52
 
@@ -61,6 +61,7 @@ PWN::AI::Agent::ToolGuard.present(opts)
61
61
  - `refuse_copied_persist`
62
62
  - `scope_refusal`
63
63
  - `policy_decision`
64
+ - `scope_check`
64
65
  - `rfc1918`
65
66
  - `ip_in_cidr`
66
67
  - `command_class`
@@ -81,6 +82,7 @@ PWN::AI::Agent::ToolGuard.present(opts)
81
82
  - `refuse_copied_persist?`
82
83
  - `reset_timeout_budget!`
83
84
  - `rfc1918?`
85
+ - `scope_check!`
84
86
  - `shell_bash?`
85
87
 
86
88
  ## Source
@@ -43,9 +43,11 @@ PWN::AI::Agent::TurnFinalizer.enter_user_path(opts)
43
43
  - `pending`
44
44
  - `arbitrate`
45
45
  - `evidence_ledger`
46
+ - `evidence_satisfied`
46
47
  - `authors`
47
48
  - `help`
48
49
  - `enter_user_path!`
50
+ - `evidence_satisfied?`
49
51
  - `join_all!`
50
52
  - `leave_user_path!`
51
53
  - `should_defer?`
@@ -38,6 +38,7 @@ PWN::Plugins::ArtifactRegistry.required_bins(opts)
38
38
  - `list`
39
39
  - `get`
40
40
  - `read_page`
41
+ - `put`
41
42
  - `authors`
42
43
  - `help`
43
44
 
@@ -52,6 +52,8 @@ PWN::Plugins::ExploitDev.required_bins(opts)
52
52
  - `gadgets`
53
53
  - `fmtstr`
54
54
  - `io`
55
+ - `scaffold`
56
+ - `from_crash`
55
57
  - `authors`
56
58
  - `help`
57
59
 
@@ -42,6 +42,7 @@ PWN::Plugins::GDB.required_bins(opts)
42
42
  - `breakpoints`
43
43
  - `crash_info`
44
44
  - `debug_session`
45
+ - `ptrace_preflight`
45
46
  - `authors`
46
47
  - `help`
47
48
 
@@ -35,6 +35,7 @@ PWN::Plugins::Jobs.required_bins(opts)
35
35
 
36
36
  - `required_bins`
37
37
  - `start`
38
+ - `watch`
38
39
  - `status`
39
40
  - `tail`
40
41
  - `result`
@@ -37,6 +37,7 @@ PWN::Plugins::PreflightChecker.required_bins(opts)
37
37
  - `bin`
38
38
  - `require_bin`
39
39
  - `route`
40
+ - `capability_coverage`
40
41
  - `pick`
41
42
  - `cap_net_raw`
42
43
  - `require_cap_net_raw`
@@ -43,6 +43,8 @@ PWN::Plugins::ProcessTube.required_bins(opts)
43
43
  - `expect`
44
44
  - `stream`
45
45
  - `reap_orphans`
46
+ - `list`
47
+ - `kill`
46
48
  - `authors`
47
49
  - `help`
48
50
 
@@ -52,7 +52,8 @@ module PWN
52
52
  args = expand_vault_args(args: args)
53
53
  end
54
54
  if defined?(Engagement)
55
- denied = Engagement.deny_if_out_of_scope(args: args, command: blob)
55
+ denied = ToolGuard.scope_check!(args: args, command: blob) if defined?(ToolGuard) && ToolGuard.respond_to?(:scope_check!)
56
+ denied ||= Engagement.deny_if_out_of_scope(args: args, command: blob)
56
57
  return JSON.generate(denied) if denied
57
58
  end
58
59
  if defined?(ToolGuard) && ToolGuard.respond_to?(:policy_decision)
@@ -71,6 +72,15 @@ module PWN
71
72
  error: 'refused: memory_remember/skills_update text copied from last tool output'
72
73
  )
73
74
  end
75
+ if blob.match?(/open_sockraw|sockraw/) && defined?(PWN::Plugins::PreflightChecker) &&
76
+ PWN::Plugins::PreflightChecker.respond_to?(:cap_net_raw?) && !PWN::Plugins::PreflightChecker.cap_net_raw?
77
+ return JSON.generate(
78
+ success: false,
79
+ error: 'capability missing CAP_NET_RAW',
80
+ substitute: 'PWN::Plugins::Packet.tcp_connect_scan',
81
+ code: 'CAP_DENY'
82
+ )
83
+ end
74
84
  result = entry.handler.call(args)
75
85
  result = ToolGuard.quarantine_output(text: result) if defined?(ToolGuard) && result.is_a?(String) && ToolGuard.respond_to?(:quarantine_output)
76
86
  note_taint(text: result)
@@ -343,8 +353,8 @@ module PWN
343
353
  return :store if STORE_TOOLS.include?(name)
344
354
 
345
355
  blob = argv_blob(args: opts[:args])
346
- return :browse if blob.match?(BROWSE_ARGV_RX)
347
356
  return :write if blob.match?(WRITE_ARGV_RX)
357
+ return :browse if blob.match?(BROWSE_ARGV_RX)
348
358
  return :eval if name == 'pwn_eval'
349
359
 
350
360
  :read
@@ -381,7 +391,7 @@ module PWN
381
391
 
382
392
  private_class_method def self.note_taint(opts = {})
383
393
  text = opts[:text].to_s
384
- grams = text.scan(/.{12,}/).first(20)
394
+ grams = text.scan(/.{24,}/).first(20)
385
395
  store = Thread.current[:pwn_taint] ||= []
386
396
  grams.each { |g| store << g[0, 64] }
387
397
  store.shift while store.length > 200
@@ -393,10 +403,10 @@ module PWN
393
403
  return false if mode == 'off'
394
404
 
395
405
  blob = opts[:args].inspect
396
- return false if blob.length < 12
406
+ return false if blob.length < 24
397
407
  return false if opts[:args].is_a?(Hash) && (opts[:args][:taint_ack] == true || opts[:args]['taint_ack'] == true)
398
408
 
399
- hit = Array(Thread.current[:pwn_taint]).any? { |g| g.length >= 12 && blob.include?(g) }
409
+ hit = Array(Thread.current[:pwn_taint]).any? { |g| g.length >= 24 && blob.include?(g) }
400
410
  return false unless hit
401
411
  return false unless blob.match?(/curl |bash -c|sh -c|\|\s*sh\b/i)
402
412
 
@@ -26,6 +26,7 @@ module PWN
26
26
  # restarts and is shared by every future session.
27
27
  module Learning
28
28
  LEARNING_FILE = File.join(Dir.home, '.pwn', 'learning.jsonl')
29
+ DISPUTED_FILE = File.join(Dir.home, '.pwn', 'learning', 'disputed.jsonl')
29
30
  LESSONS_FILE = File.join(Dir.home, '.pwn', 'lessons.json')
30
31
  FINETUNE_DIR = File.join(Dir.home, '.pwn', 'finetune')
31
32
  # P0 — post-answer introspect must not train "stop early" while
@@ -138,6 +139,12 @@ module PWN
138
139
  entry[:success] = true
139
140
  success = true
140
141
  end
142
+ check = consistency_check(details: details, success: success, rationale: opts[:rationale])
143
+ if check == :disputed
144
+ entry[:status] = 'disputed'
145
+ disputed_save(entry: entry)
146
+ return entry
147
+ end
141
148
  FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
142
149
  File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
143
150
  maybe_prune_outcomes!
@@ -159,6 +166,22 @@ module PWN
159
166
  entry
160
167
  end
161
168
 
169
+ public_class_method def self.consistency_check(opts = {})
170
+ details = "#{opts[:details]} #{opts[:rationale]}"
171
+ success = opts[:success]
172
+ return :disputed if details.match?(/\bPASS\b/) && success == false
173
+ return :disputed if details.match?(/\bFAIL\b/) && success == true && !details.match?(/\bPASS\b/)
174
+
175
+ :ok
176
+ end
177
+
178
+ public_class_method def self.disputed_save(opts = {})
179
+ entry = opts[:entry] || {}
180
+ FileUtils.mkdir_p(File.dirname(DISPUTED_FILE))
181
+ File.open(DISPUTED_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
182
+ entry
183
+ end
184
+
162
185
  # Supported Method Parameters::
163
186
  # rows = PWN::AI::Agent::Learning.outcomes(
164
187
  # limit: 'optional - max entries returned newest-first (default 50)',
@@ -196,11 +219,21 @@ module PWN
196
219
  jmean = total.positive? ? weighted_judge_mean(rows: rows) : nil
197
220
  distrust = 0.0
198
221
  distrust = Reward.proxy_distrust.to_f.clamp(0.0, 1.0) if defined?(Reward) && Reward.respond_to?(:proxy_distrust)
222
+ orm = rows.select { |r| r[:judge_source].to_s != 'heuristic' && r[:source].to_s != 'heuristic' }
223
+ heur = rows.select { |r| r[:judge_source].to_s == 'heuristic' || r[:source].to_s == 'heuristic' }
224
+ orm_n = orm.length
225
+ heur_n = heur.length
226
+ orm_ok = orm.count { |r| r[:success] == true }
227
+ heur_ok = heur.count { |r| r[:success] == true }
199
228
  {
200
229
  total_outcomes: total,
201
230
  successes: ok,
202
231
  failures: total - ok,
203
232
  success_rate: raw,
233
+ success_rate_orm: orm_n.positive? ? (orm_ok.to_f / orm_n).round(3) : 0.0,
234
+ success_rate_heur: heur_n.positive? ? (heur_ok.to_f / heur_n).round(3) : 0.0,
235
+ orm_n: orm_n,
236
+ heur_n: heur_n,
204
237
  adjusted_success_rate: discount_success_rate(proxy: raw, judge: jmean, distrust: distrust),
205
238
  proxy_distrust: distrust,
206
239
  skills_known: skills,
@@ -227,6 +260,12 @@ module PWN
227
260
  fails = prefer_primary_tasks(rows: outcomes(limit: 200, success: false))
228
261
  fails = fails.reject { |r| r[:status].to_s == 'conflicted' }
229
262
  fails = fails.reject { |r| r[:verifier_verdict].to_s == 'pass' }
263
+ fails = fails.reject { |r| r[:details].to_s.match?(/\bPASS\b/) && r[:success] == true }
264
+ fails = fails.select do |r|
265
+ v = r[:verdict].to_s
266
+ v == 'wrong' || v == 'refused' || (r[:score].to_f < 0.3 && !r[:details].to_s.match?(/\bPASS\b/)) || r[:success] == false
267
+ end
268
+ fails = fails.reject { |r| r[:success] == true }
230
269
  # Do not mirror the same ids under both headings — that doubled the
231
270
  # failure signal and made RECENT OUTCOMES == RECENT FAILURES when the
232
271
  # last N attempts all failed (the injected block looked "stuck").
@@ -260,7 +299,7 @@ module PWN
260
299
  d = s[:proxy_distrust].to_f
261
300
  rate = d > 0.05 ? s[:adjusted_success_rate] : s[:success_rate]
262
301
  tag = d > 0.05 ? ' adj' : ''
263
- hdr = "RECENT OUTCOMES (success_rate=#{(rate.to_f * 100).round(1)}%#{tag}#{" judge_mean=#{jm}" if jm} over #{s[:total_outcomes]} attempts)"
302
+ hdr = "RECENT OUTCOMES (success_rate=#{(rate.to_f * 100).round(1)}%#{tag} success=orm:#{(s[:success_rate_orm].to_f * 100).round(1)}%(#{s[:orm_n]}) / heur:#{(s[:success_rate_heur].to_f * 100).round(1)}%(#{s[:heur_n]})#{" judge_mean=#{jm}" if jm} over #{s[:total_outcomes]} attempts)"
264
303
  out = "#{hdr}\n#{rows.map(&fmt).join("\n")}\n"
265
304
  out += "RECENT FAILURES (learn from these — do not repeat)\n#{fails.map(&fmt).join("\n")}\n" unless fails.empty?
266
305
  "#{out}\n"
@@ -2065,6 +2104,18 @@ module PWN
2065
2104
  include_demoted: 'optional - include demoted lessons (defaults to false)'
2066
2105
  )
2067
2106
 
2107
+ # Refuse PASS+success:false rows; they go to the disputed queue.
2108
+ #{self}.consistency_check(
2109
+ details: 'optional - details string that may contain PASS/FAIL',
2110
+ rationale: 'optional - judge rationale text',
2111
+ success: 'required - boolean success flag'
2112
+ )
2113
+
2114
+ # Append a disputed outcome to ~/.pwn/learning/disputed.jsonl.
2115
+ #{self}.disputed_save(
2116
+ entry: 'required - Hash of the disputed learning row'
2117
+ )
2118
+
2068
2119
  # Print the AUTHOR(S) string for this module.
2069
2120
  #{self}.authors
2070
2121
  "
@@ -64,6 +64,18 @@ module PWN
64
64
  unsatisfied incomplete_final empty_final evidence_final
65
65
  ].freeze
66
66
 
67
+ public_class_method def self.evidence_satisfied?(opts = {})
68
+ messages = Array(opts[:messages] || opts[:trace])
69
+ text = opts[:text].to_s
70
+ if defined?(TurnFinalizer) && TurnFinalizer.respond_to?(:arbitrate)
71
+ row = TurnFinalizer.arbitrate(request: opts[:request].to_s, messages: messages, paths: [])
72
+ return true if row[:complete] && row[:unmet].empty? && row[:ledger].any? { |_p, v| v[:write] && v[:read] }
73
+ end
74
+ write_or_read_evidenced?(messages: messages) && !text.strip.empty?
75
+ rescue StandardError
76
+ false
77
+ end
78
+
67
79
  public_class_method def self.debug_on?(opts = {})
68
80
  return true if opts[:debug]
69
81
  return true if defined?(PWN::Plugins::Log) && PWN::Plugins::Log.debug_enabled?
@@ -394,6 +406,7 @@ module PWN
394
406
  # the original request → force synthesis. English tasks are an advisory
395
407
  # compass only — an open verify tail must not block a finished ask.
396
408
  # The original request is the completion signal.
409
+
397
410
  private_class_method def self.evidence_enough_to_finalize?(opts = {})
398
411
  messages = Array(opts[:messages])
399
412
  turn_fails = opts[:turn_fails] || {}
@@ -595,7 +608,7 @@ module PWN
595
608
  request: request,
596
609
  messages: opts[:messages]
597
610
  )
598
- return true if need == :write && !write_verified?(effects: effects)
611
+ return true if need == :write && !write_verified?(effects: effects, request: request, messages: opts[:messages])
599
612
  return true if need == :browse && !effects.include?(:browse)
600
613
  return true if need == :any && !effects.intersect?(%i[write browse eval])
601
614
 
@@ -607,10 +620,27 @@ module PWN
607
620
  private_class_method def self.write_verified?(opts = {})
608
621
  effects = Array(opts[:effects])
609
622
  idx = effects.index(:write)
610
- return false if idx.nil?
623
+ if idx
624
+ tail = effects[(idx + 1)..] || []
625
+ return true if tail.intersect?(%i[read eval])
626
+ end
611
627
 
612
- tail = effects[(idx + 1)..] || []
613
- tail.intersect?(%i[read eval])
628
+ saw_write_argv = false
629
+ Array(opts[:messages]).each do |msg|
630
+ next unless msg.is_a?(Hash)
631
+
632
+ if msg[:role].to_s == 'assistant' && defined?(Dispatch)
633
+ Array(msg[:tool_calls]).each do |tc|
634
+ blob = Dispatch.send(:argv_blob, args: tc.dig(:function, :arguments) || tc.dig('function', 'arguments'))
635
+ saw_write_argv = true if blob.to_s.match?(Dispatch::WRITE_ARGV_RX)
636
+ end
637
+ elsif saw_write_argv && msg[:role].to_s == 'tool'
638
+ fx = stamped_effect(content: msg[:content])
639
+ return true if %i[read eval].include?(fx)
640
+ end
641
+ end
642
+
643
+ false
614
644
  rescue StandardError
615
645
  false
616
646
  end
@@ -875,6 +905,7 @@ module PWN
875
905
  end
876
906
 
877
907
  private_class_method def self.may_finalize?(opts = {})
908
+ return true if evidence_satisfied?(messages: opts[:messages], text: opts[:text], request: opts[:request])
878
909
  return false if incomplete_final?(text: opts[:text], last_iter: false)
879
910
  return false if request_unsatisfied?(
880
911
  request: opts[:request],
@@ -1145,6 +1176,16 @@ module PWN
1145
1176
  false
1146
1177
  end
1147
1178
 
1179
+ private_class_method def self.session_tool_budget(opts = {})
1180
+ name = opts[:name].to_s
1181
+ result = opts[:result].to_s
1182
+ return 16_384 if name.match?(/decompile|binary_triage|artifact_read|exploitdev/)
1183
+ return 16_384 if name == 'pwn_eval' && result.match?(/r2 |objdump|xxd |pdf\b|afl-/)
1184
+ return 16_384 if name == 'shell' && result.match?(/objdump|xxd|radare|r2 |disassembl/)
1185
+
1186
+ 1_024
1187
+ end
1188
+
1148
1189
  # E1 — did the environment change under this tool? If Metrics CUSUM
1149
1190
  # tripped for it in the last hour AND Extrospection.drift shows a
1150
1191
  # toolchain/net/repo change, blame the WORLD not the AGENT.
@@ -1678,6 +1719,7 @@ module PWN
1678
1719
  }
1679
1720
  # Ollama + abliterated / weak chat-templates often ignore tools: and
1680
1721
  # answer in prose (or print shell(...) as text). Force native
1722
+
1681
1723
  # tool_calls until at least one tool result is already in history;
1682
1724
  # after that, auto so the model can emit a real final answer.
1683
1725
  # Respect explicit PWN::Env[:ai][:ollama][:tool_choice] override.
@@ -1705,10 +1747,6 @@ module PWN
1705
1747
  end
1706
1748
  end
1707
1749
 
1708
- # 3.1 — sliding-window history compaction for local models.
1709
- # Keep: system, original user, PLAN assistant (if any), last K tool
1710
- # pairs (assistant+tool), and the most recent assistant. Stale tool
1711
- # bodies are truncated to history_tool_max_chars.
1712
1750
  private_class_method def self.session_chat_history(opts = {})
1713
1751
  return [] unless defined?(PWN::Sessions) && PWN::Sessions.respond_to?(:to_llm_messages)
1714
1752
 
@@ -1831,15 +1869,15 @@ module PWN
1831
1869
 
1832
1870
  private_class_method def self.spill_tool_body(opts = {})
1833
1871
  text = opts[:text].to_s
1834
- digest = Digest::SHA256.hexdigest(text)[0, 16]
1835
- dir = HISTORY_SPILL_DIR
1872
+ digest = Digest::SHA256.hexdigest(text)
1873
+ dir = File.join(Dir.home, '.pwn', 'artifacts', 'transcripts', (Thread.current[:pwn_session_id] || 'default').to_s)
1836
1874
  FileUtils.mkdir_p(dir)
1837
- path = File.join(dir, "#{digest}.txt")
1875
+ path = File.join(dir, "#{digest[0, 12]}.txt")
1838
1876
  File.binwrite(path, text) unless File.file?(path)
1839
1877
  head = text.byteslice(0, 2_048).to_s
1840
1878
  tail = text.bytesize > 3_072 ? text.byteslice(-1_024, 1_024).to_s : ''
1841
1879
  mid = tail.empty? ? '' : "\n...\n#{tail}"
1842
- "#{head}#{mid}\n[compacted path=#{path} sha256=#{digest} bytes=#{text.bytesize}]"
1880
+ "#{head}#{mid}\n[compacted path=#{path} sha256=#{digest} bytes=#{text.bytesize} ref=#{path}]"
1843
1881
  rescue StandardError
1844
1882
  "[compacted bytes=#{opts[:text].to_s.bytesize}]"
1845
1883
  end
@@ -3002,17 +3040,21 @@ module PWN
3002
3040
  text: text
3003
3041
  )
3004
3042
  turn_fails['unsatisfied'] += 1
3005
- unmet = completion_unmet(request: request, messages: messages)
3006
- warn "[pwn-ai/loop] original request not evidenced on iter=#{i} unmet=#{unmet.join(',')}; continuing"
3007
- debug_progress(msg: "bounce unsatisfied unmet=#{unmet.join(',')} snippet=#{debug_snippet(text: text)}")
3008
- messages << {
3009
- role: 'user',
3010
- content: "[pwn-ai] The original request is not evidenced yet. unmet=#{unmet.join(',')} " \
3011
- 'Keep calling CORE_TOOLS (shell, pwn_eval) until that request is ' \
3012
- 'done or a tool returned failure evidence. pwn-ai does not decide ' \
3013
- 'authorization. Do not declare completion from a listing or a refusal.'
3014
- }
3015
- next
3043
+ if turn_fails['unsatisfied'] >= 2 && evidence_satisfied?(request: request, messages: messages, text: text)
3044
+ debug_progress(msg: 'nag cap: evidence_satisfied after 2 bounces')
3045
+ else
3046
+ unmet = completion_unmet(request: request, messages: messages)
3047
+ warn "[pwn-ai/loop] original request not evidenced on iter=#{i} unmet=#{unmet.join(',')}; continuing"
3048
+ debug_progress(msg: "bounce unsatisfied unmet=#{unmet.join(',')} snippet=#{debug_snippet(text: text)}")
3049
+ messages << {
3050
+ role: 'user',
3051
+ content: "[pwn-ai] The original request is not evidenced yet. unmet=#{unmet.join(',')} " \
3052
+ 'Keep calling CORE_TOOLS (shell, pwn_eval) until that request is ' \
3053
+ 'done or a tool returned failure evidence. pwn-ai does not decide ' \
3054
+ 'authorization. Do not declare completion from a listing or a refusal.'
3055
+ }
3056
+ next
3057
+ end
3016
3058
  end
3017
3059
  debug_progress(msg: "final accepted chars=#{text.to_s.length}")
3018
3060
  quiet_debug_tui!(reason: 'final')
@@ -3094,7 +3136,7 @@ module PWN
3094
3136
  append_session(
3095
3137
  session_id: session_id,
3096
3138
  role: 'tool',
3097
- content: "#{name} → #{result[0, 1_024]}"
3139
+ content: "#{name} → #{result[0, session_tool_budget(name: name, result: result)]}"
3098
3140
  )
3099
3141
  end
3100
3142
 
@@ -3163,8 +3205,6 @@ module PWN
3163
3205
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
3164
3206
  end
3165
3207
 
3166
- # Display Usage for this Module
3167
-
3168
3208
  public_class_method def self.help
3169
3209
  puts "USAGE:
3170
3210
  # Run debug on and return its result
@@ -3172,6 +3212,14 @@ module PWN
3172
3212
  debug: 'optional - debug value consumed by #debug_on?'
3173
3213
  )
3174
3214
 
3215
+ # True when write-then-readback evidence satisfies the original request.
3216
+ #{self}.evidence_satisfied?(
3217
+ messages: 'optional - Array of role/content hashes',
3218
+ trace: 'optional - alias for messages',
3219
+ text: 'optional - final answer text',
3220
+ request: 'optional - original request'
3221
+ )
3222
+
3175
3223
  # True only when the ask needs a live host/file/browser effect. World-knowledge
3176
3224
  #{self}.catalog_lookup?(
3177
3225
  request: 'required - request value consumed by #catalog_lookup?'
@@ -99,9 +99,32 @@ module PWN
99
99
  bump(bucket: e, success: success, duration: duration, error: error)
100
100
  end
101
101
  save(metrics: metrics)
102
+ append_jsonl(opts.merge(name: name, success: success, duration: duration, error: error, engine: engine))
102
103
  t
103
104
  end
104
105
 
106
+ public_class_method def self.append_jsonl(opts = {})
107
+ path = File.join(Dir.home, '.pwn', 'logs', 'tool_metrics.jsonl')
108
+ FileUtils.mkdir_p(File.dirname(path))
109
+ row = {
110
+ ts: Time.now.utc.iso8601,
111
+ session: Thread.current[:pwn_session_id],
112
+ tool: opts[:name],
113
+ latency_ms: (opts[:duration].to_f * 1000).round,
114
+ timeout_used: opts[:timeout],
115
+ outcome: opts[:success] ? 'ok' : 'err',
116
+ error_class: opts[:error].to_s.split(':').first,
117
+ bytes_out: opts[:bytes_out].to_i
118
+ }
119
+ File.open(path, 'a') do |f|
120
+ f.flock(File::LOCK_EX)
121
+ f.puts(JSON.generate(row))
122
+ end
123
+ row
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
105
128
  # Supported Method Parameters::
106
129
  # rows = PWN::AI::Agent::Metrics.summary(
107
130
  # limit: 'optional - cap number of tools returned (default 25)',
@@ -133,6 +156,19 @@ module PWN
133
156
  .sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit)
134
157
  end
135
158
 
159
+ public_class_method def self.snapshot(opts = {})
160
+ _day = opts[:day]
161
+ learn = defined?(Learning) ? Learning.stats : {}
162
+ {
163
+ success_rate: learn[:success_rate],
164
+ success_rate_orm: learn[:success_rate_orm],
165
+ success_rate_heur: learn[:success_rate_heur],
166
+ brier: (learn.dig(:calibration, :brier) if learn.is_a?(Hash)),
167
+ proxy_distrust: learn[:proxy_distrust],
168
+ tools: summary(limit: 8)
169
+ }
170
+ end
171
+
136
172
  # Supported Method Parameters::
137
173
  # ctx = PWN::AI::Agent::Metrics.to_context(
138
174
  # limit: 'optional - cap number of tools included (default 8)',
@@ -664,11 +700,22 @@ module PWN
664
700
  #{self}.record(
665
701
  name: 'required - tool name that was dispatched',
666
702
  success: 'required - Boolean, did the handler complete without error',
667
- duration: 'optional - Float seconds the dispatch took',
703
+ duration: 'optional - Float seconds the dispatch took (wall time)',
668
704
  error: 'optional - String error message when success is false',
669
705
  engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)'
670
706
  )
671
707
 
708
+ # Append one JSONL telemetry row under ~/.pwn/logs/tool_metrics.jsonl.
709
+ #{self}.append_jsonl(
710
+ name: 'required - tool name',
711
+ success: 'optional - Boolean outcome',
712
+ duration: 'optional - Float seconds the dispatch took (wall time)',
713
+ error: 'optional - error string',
714
+ engine: 'optional - engine name',
715
+ timeout: 'optional - timeout used',
716
+ bytes_out: 'optional - output byte count'
717
+ )
718
+
672
719
  # Run summary and return its result
673
720
  #{self}.summary(
674
721
  limit: 'optional - cap number of tools returned (default 25)',
@@ -780,6 +827,11 @@ module PWN
780
827
  # Run reset and return its result
781
828
  #{self}.reset
782
829
 
830
+ # Snapshot success rates per judge source for the LEARNING block.
831
+ #{self}.snapshot(
832
+ day: 'optional - reserved day key for rotation'
833
+ )
834
+
783
835
  # Print the AUTHOR(S) string for this module.
784
836
  #{self}.authors
785
837
  "