pwn 0.5.721 → 0.5.723

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/bin/pwn_setup +5 -5
  4. data/documentation/AI-Integration.md +34 -1
  5. data/documentation/Policy-Benchmark.md +151 -0
  6. data/documentation/Reinforcement-Learning.md +27 -1
  7. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
  10. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
  13. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
  14. data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
  17. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
  18. data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
  19. data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
  21. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  24. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
  25. data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
  26. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  29. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
  31. data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
  32. data/lib/pwn/ai/agent/curriculum.rb +37 -45
  33. data/lib/pwn/ai/agent/dispatch.rb +9 -0
  34. data/lib/pwn/ai/agent/engagement.rb +59 -0
  35. data/lib/pwn/ai/agent/learning.rb +111 -44
  36. data/lib/pwn/ai/agent/loop.rb +50 -8
  37. data/lib/pwn/ai/agent/metrics.rb +70 -2
  38. data/lib/pwn/ai/agent/mistakes.rb +67 -1
  39. data/lib/pwn/ai/agent/policy.rb +142 -16
  40. data/lib/pwn/ai/agent/registry.rb +1 -1
  41. data/lib/pwn/ai/agent/reward.rb +153 -53
  42. data/lib/pwn/ai/agent/swarm.rb +235 -35
  43. data/lib/pwn/ai/agent/tool_guard.rb +13 -1
  44. data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
  45. data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
  46. data/lib/pwn/ai/agent/tools/context.rb +38 -0
  47. data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
  48. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
  49. data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
  50. data/lib/pwn/ai/agent/tools/learning.rb +5 -6
  51. data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
  52. data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
  53. data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
  54. data/lib/pwn/ai/agent/tools/shell.rb +10 -1
  55. data/lib/pwn/ai/agent/tools/skills.rb +30 -0
  56. data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
  57. data/lib/pwn/ai/context.rb +193 -0
  58. data/lib/pwn/ai/http_retry.rb +53 -7
  59. data/lib/pwn/ai/open_ai.rb +302 -45
  60. data/lib/pwn/ai.rb +1 -0
  61. data/lib/pwn/migrate.rb +10 -1
  62. data/lib/pwn/plugins/artifact_registry.rb +40 -17
  63. data/lib/pwn/plugins/binary_parser.rb +4 -1
  64. data/lib/pwn/plugins/exploit_dev.rb +15 -0
  65. data/lib/pwn/plugins/findings.rb +48 -8
  66. data/lib/pwn/plugins/gdb.rb +17 -0
  67. data/lib/pwn/plugins/gdbmi.rb +128 -0
  68. data/lib/pwn/plugins/ghidra_headless.rb +104 -0
  69. data/lib/pwn/plugins/jobs.rb +72 -0
  70. data/lib/pwn/plugins/packet.rb +51 -0
  71. data/lib/pwn/plugins/preflight_checker.rb +29 -0
  72. data/lib/pwn/plugins/process_tube.rb +24 -7
  73. data/lib/pwn/plugins/radare2.rb +14 -2
  74. data/lib/pwn/plugins/repl.rb +6 -1
  75. data/lib/pwn/plugins/transparent_browser.rb +64 -0
  76. data/lib/pwn/plugins.rb +2 -0
  77. data/lib/pwn/reports/engagement.rb +19 -0
  78. data/lib/pwn/sessions.rb +3 -1
  79. data/lib/pwn/version.rb +1 -1
  80. data/scripts/benchmark_policy.rb +276 -0
  81. data/spec/documentation/installation_md_spec.rb +18 -4
  82. data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
  83. data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
  84. data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
  85. data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
  86. data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
  87. data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
  88. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
  89. data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
  90. data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
  91. data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
  92. data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
  93. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
  94. data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
  95. data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
  96. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
  97. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
  98. data/spec/lib/pwn/ai/context_spec.rb +48 -0
  99. data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
  100. data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
  101. data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
  102. data/spec/lib/pwn/migrate_spec.rb +24 -0
  103. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
  104. data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
  105. data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
  106. data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
  107. data/third_party/pwn_rdoc.jsonl +86 -2
  108. metadata +24 -5
@@ -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
@@ -102,18 +103,20 @@ module PWN
102
103
 
103
104
  tags = Array(opts[:tags]).map(&:to_s)
104
105
  details = opts[:details].to_s[0, OUTCOME_DETAILS_MAX]
105
- # Refuse solved-at-fail (and the reverse): when a score is present,
106
- # tags / details / boolean success must match verdict_for_score.
107
- # Soft HER stays soft so C3 positives are not flipped to true/false.
108
- if opts.key?(:score)
109
- score = opts[:score].to_f
110
- want = verdict_for_score(score: score).to_s
106
+ decision = if opts[:outcome].is_a?(Hash)
107
+ Reward.resolve_outcome(outcome: opts[:outcome])
108
+ elsif opts.key?(:score) && success != 'soft'
109
+ Reward.resolve_outcome(outcome: { score: opts[:score], source: opts[:judge_source] || :manual, confidence: opts[:confidence] })
110
+ end
111
+ if decision || opts.key?(:score)
112
+ score = decision ? decision[:score] : opts[:score].to_f
113
+ want = (decision ? decision[:verdict] : verdict_for_score(score: score)).to_s
111
114
  tags = (tags - %w[solved partial wrong unknown]) << want
112
115
  details = details.sub(
113
116
  /\A(solved|partial|wrong|unknown)\(\d+(?:\.\d+)?\)/i,
114
- "#{want}(#{format('%.2f', score)})"
117
+ "#{want}(#{score.nil? ? 'unknown' : format('%.2f', score)})"
115
118
  )
116
- success = (score >= 0.6) if [true, false].include?(success)
119
+ success = decision[:success] if decision
117
120
  end
118
121
 
119
122
  entry = {
@@ -125,18 +128,27 @@ module PWN
125
128
  tags: tags,
126
129
  timestamp: Time.now.utc.iso8601
127
130
  }
128
- entry[:score] = opts[:score].to_f if opts.key?(:score)
131
+ entry[:score] = score if decision || opts.key?(:score)
132
+ if decision
133
+ %i[verdict confidence training_score decision_version verification verifier_verdict grounded critic_pass judge_score].each do |key|
134
+ entry[key] = decision[key] if decision.key?(key)
135
+ end
136
+ entry[:status] = 'unverified' if decision[:training_score].nil?
137
+ end
129
138
  src = opts[:judge_source].to_s
139
+ src = decision[:source].to_s if decision && decision[:source]
130
140
  entry[:judge_source] = src unless src.empty?
131
- vv = (opts[:verifier_verdict] || opts['verifier_verdict']).to_s
141
+ vv = (decision ? decision[:verifier_verdict] : opts[:verifier_verdict] || opts['verifier_verdict']).to_s
132
142
  entry[:verifier_verdict] = vv unless vv.empty?
133
143
  vc = (opts[:verdict_class] || opts['verdict_class']).to_s
134
144
  entry[:verdict_class] = vc unless vc.empty?
135
145
  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
146
+ entry[:status] = 'conflicted' if opts[:verifier_verdict].to_s == 'pass' && !entry[:verification] && opts[:score].to_f < 0.6
147
+ check = consistency_check(details: details, success: success, rationale: opts[:rationale])
148
+ if check == :disputed
149
+ entry[:status] = 'disputed'
150
+ disputed_save(entry: entry)
151
+ return entry
140
152
  end
141
153
  FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
142
154
  File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
@@ -147,18 +159,33 @@ module PWN
147
159
  # are promoted into PWN::Memory[:lesson] so PromptBuilder recall
148
160
  # survives across sessions. Without this, the agent re-learns
149
161
  # "run rubocop after every patch" every turn (empty memory.json).
150
- promote_process_lesson(entry: entry) if defined?(PWN::Memory) && entry[:status].to_s != 'conflicted'
151
- if opts.key?(:score) && defined?(Curriculum) && Curriculum.respond_to?(:calibrate)
162
+ promote_process_lesson(entry: entry) if defined?(PWN::Memory) && !%w[conflicted unverified].include?(entry[:status].to_s)
163
+ if opts.key?(:score) && (!decision || !decision[:training_score].nil?) && defined?(Curriculum) && Curriculum.respond_to?(:calibrate)
152
164
  pred = opts[:predicted]
153
165
  pred = Thread.current[:pwn_plan_predicted] if pred.nil?
154
166
  pred = opts[:confidence] if pred.nil?
155
167
  eng = opts[:engine]
156
168
  eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)) if eng.to_s.empty?
157
- Curriculum.calibrate(predicted: pred, actual: opts[:score], engine: eng)
169
+ Curriculum.calibrate(predicted: pred, actual: decision ? decision[:training_score] : opts[:score], engine: eng)
158
170
  end
159
171
  entry
160
172
  end
161
173
 
174
+ public_class_method def self.consistency_check(opts = {})
175
+ outcome = opts[:outcome]
176
+ return :ok unless outcome.is_a?(Hash)
177
+ return :disputed if Reward.resolve_outcome(outcome: outcome)[:success] != opts[:success]
178
+
179
+ :ok
180
+ end
181
+
182
+ public_class_method def self.disputed_save(opts = {})
183
+ entry = opts[:entry] || {}
184
+ FileUtils.mkdir_p(File.dirname(DISPUTED_FILE))
185
+ File.open(DISPUTED_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
186
+ entry
187
+ end
188
+
162
189
  # Supported Method Parameters::
163
190
  # rows = PWN::AI::Agent::Learning.outcomes(
164
191
  # limit: 'optional - max entries returned newest-first (default 50)',
@@ -178,6 +205,7 @@ module PWN
178
205
  nil
179
206
  end
180
207
  rows.compact!
208
+ rows.reject! { |r| r[:decision_version] && r[:training_score].nil? } unless want_ok.nil?
181
209
  rows.select! { |r| want_ok == true ? r[:success] == true : r[:success] == want_ok } unless want_ok.nil?
182
210
  rows.select! { |r| Array(r[:tags]).any? { |t| t.to_s.downcase.include?(tag) } } unless tag.empty?
183
211
  rows.reverse.first(limit)
@@ -189,11 +217,13 @@ module PWN
189
217
  public_class_method def self.stats
190
218
  rows = outcomes(limit: 10_000)
191
219
  total = rows.length
220
+ rows = rows.reject { |r| (r[:decision_version] && r[:training_score].nil?) || r[:success].nil? }
221
+ evaluated = rows.length
192
222
  ok = rows.count { |r| r[:success] == true }
193
223
  skills = defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) ? PWN::Skills.keys.length : 0
194
224
  mem = defined?(PWN::Memory) ? PWN::Memory.load.keys.length : 0
195
- raw = total.positive? ? (ok.to_f / total).round(3) : 0.0
196
- jmean = total.positive? ? weighted_judge_mean(rows: rows) : nil
225
+ raw = evaluated.positive? ? (ok.to_f / evaluated).round(3) : 0.0
226
+ jmean = evaluated.positive? ? weighted_judge_mean(rows: rows) : nil
197
227
  distrust = 0.0
198
228
  distrust = Reward.proxy_distrust.to_f.clamp(0.0, 1.0) if defined?(Reward) && Reward.respond_to?(:proxy_distrust)
199
229
  orm = rows.select { |r| r[:judge_source].to_s != 'heuristic' && r[:source].to_s != 'heuristic' }
@@ -204,8 +234,9 @@ module PWN
204
234
  heur_ok = heur.count { |r| r[:success] == true }
205
235
  {
206
236
  total_outcomes: total,
237
+ unknown_outcomes: total - evaluated,
207
238
  successes: ok,
208
- failures: total - ok,
239
+ failures: rows.count { |r| r[:success] == false },
209
240
  success_rate: raw,
210
241
  success_rate_orm: orm_n.positive? ? (orm_ok.to_f / orm_n).round(3) : 0.0,
211
242
  success_rate_heur: heur_n.positive? ? (heur_ok.to_f / heur_n).round(3) : 0.0,
@@ -251,17 +282,22 @@ module PWN
251
282
  return '' if rows.empty? && fails.empty?
252
283
 
253
284
  fmt = lambda do |r|
285
+ unknown = r[:status].to_s == 'unverified' || (r[:decision_version] && r[:training_score].nil?)
254
286
  flag = case r[:success]
255
287
  when true then '✓'
256
288
  when 'soft', :soft then '∼'
257
289
  else '✗'
258
290
  end
259
291
  score = r.key?(:score) ? format('%.2f', r[:score].to_f) : '-'
292
+ if unknown
293
+ flag = '?'
294
+ score = 'unknown'
295
+ end
260
296
  task = display_task(task: r[:task])
261
297
  line = " #{flag} [#{score}] #{task} (#{r[:timestamp]})"
262
298
  # Surface a one-line cause crumb so the agent can actually learn
263
299
  # from failures instead of only seeing that they failed.
264
- if r[:success] != true
300
+ if r[:success] != true && !unknown
265
301
  if r[:verdict_class].to_s == ''
266
302
  crumb = cause_crumb(details: r[:details])
267
303
  line += "\n cause: #{crumb}" unless crumb.empty?
@@ -609,14 +645,10 @@ module PWN
609
645
 
610
646
  # R1 judge — always attempt (heuristic is cheap; LLM gated inside)
611
647
  stages_run << :judge
612
- v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok, predicted: opts[:predicted]) if defined?(Reward)
613
- v ||= { score: proxy_ok ? 1.0 : 0.0, success: proxy_ok, verdict: proxy_ok ? :solved : :wrong }
614
- v[:score] = [v[:score], 0.3].min if crit[:verdict] == :flaw && v[:score].to_f < 0.6
615
- # P29 critic floor used to leave stale verdict=:solved at score=0.3,
616
- # producing learning.jsonl rows tagged "solved" with success=false
617
- # (116+ rows). Always resync verdict/success from the final score.
618
- v[:verdict] = verdict_for_score(score: v[:score])
619
- v[:success] = v[:score].to_f >= 0.6
648
+ critic_pass = crit[:verdict] == :flaw ? false : nil
649
+ v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok, predicted: opts[:predicted], critic_pass: critic_pass) if defined?(Reward)
650
+ v ||= { score: nil, source: :error, verdict: :unknown, success: nil }
651
+ v = Reward.resolve_outcome(outcome: v, critic_pass: critic_pass)
620
652
  ok = v[:success]
621
653
 
622
654
  # W1 pending user_correction pair
@@ -664,20 +696,23 @@ module PWN
664
696
  task: task_txt,
665
697
  success: ok,
666
698
  score: v[:score],
699
+ outcome: v,
667
700
  details: "#{v[:verdict]}(#{v[:score].to_f.round(2)}) #{v[:rationale]} | #{opts[:final].to_s[0, 200]}",
668
701
  session_id: session_id,
669
702
  tags: outcome_tags,
670
703
  judge_source: v[:source]
671
704
  )
672
705
 
673
- stages_run << :fold_judge
674
- fold_judge_into_metrics(session_id: session_id, score: v[:score], confidence: v[:confidence])
706
+ unless v[:training_score].nil?
707
+ stages_run << :fold_judge
708
+ fold_judge_into_metrics(session_id: session_id, score: v[:training_score], confidence: v[:confidence])
709
+ end
675
710
  # R5 — close the live MDP episode with the ORM terminal reward.
676
711
  if defined?(PWN::AI::Agent::Policy) && Policy.respond_to?(:finish)
677
712
  stages_run << :policy
678
713
  Policy.finish(
679
714
  session_id: session_id,
680
- score: v[:score],
715
+ score: v[:training_score],
681
716
  confidence: v[:confidence],
682
717
  verdict: v[:verdict],
683
718
  proxy_ok: ok,
@@ -687,7 +722,7 @@ module PWN
687
722
  end
688
723
 
689
724
  # R2 PRM — skip under hard cap (expensive LLM); keep under soft if heuristic path
690
- if over_hard.call || !defined?(Reward) || v[:score].to_f < 0.6
725
+ if over_hard.call || !defined?(Reward) || v[:training_score].nil? || !ok
691
726
  stages_skipped << :prm
692
727
  else
693
728
  stages_run << :prm
@@ -695,7 +730,7 @@ module PWN
695
730
  end
696
731
 
697
732
  # C3 HER — only on failure; skip hard
698
- if !ok && defined?(Curriculum) && !over_hard.call
733
+ if !ok && !v[:training_score].nil? && defined?(Curriculum) && !over_hard.call
699
734
  stages_run << :hindsight
700
735
  Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id)
701
736
  else
@@ -711,7 +746,7 @@ module PWN
711
746
  # M4.1 — also reflect when the request/final is a process SOP
712
747
  # (code hygiene) even if judge score < 0.6, so rubocop/rake
713
748
  # lessons still land in PWN::Memory.
714
- process_sop = process_sop_text?(text: "#{opts[:request]} #{opts[:final]}")
749
+ process_sop = !v[:training_score].nil? && process_sop_text?(text: "#{opts[:request]} #{opts[:final]}")
715
750
  if (ok || process_sop) && !over_soft.call
716
751
  stages_run << :reflect
717
752
  reflect(session_id: session_id)
@@ -1105,6 +1140,8 @@ module PWN
1105
1140
  lines = File.readlines(LEARNING_FILE)
1106
1141
  out = lines.map do |l|
1107
1142
  r = JSON.parse(l, symbolize_names: true)
1143
+ next l if r[:decision_version]
1144
+
1108
1145
  score = r.key?(:score) ? r[:score].to_f : nil
1109
1146
  next l if score.nil?
1110
1147
 
@@ -1874,14 +1911,31 @@ module PWN
1874
1911
 
1875
1912
  n = 0
1876
1913
  rows.each do |r|
1877
- note_outcome(
1878
- task: "requeue:#{r[:task]}",
1879
- success: true,
1880
- score: [r[:score].to_f, 0.7].max,
1881
- verifier_verdict: :pass,
1882
- details: 'rescored after verifier precedence',
1883
- tags: %w[requeue]
1914
+ next if r[:session_id].to_s.empty?
1915
+
1916
+ transcript = PWN::Sessions.load(session_id: r[:session_id])
1917
+ user_idx = transcript.rindex { |entry| entry[:role].to_s == 'user' }
1918
+ next unless user_idx
1919
+
1920
+ request = transcript[user_idx][:content].to_s
1921
+ final = transcript[(user_idx + 1)..].reverse.find { |entry| entry[:role].to_s == 'assistant' }
1922
+ next unless final && (request == r[:task].to_s || display_task(task: request) == r[:task].to_s)
1923
+
1924
+ outcome = Reward.judge(request: request, final: final[:content], session_id: r[:session_id], commit: false)
1925
+ fresh = note_outcome(
1926
+ task: request,
1927
+ session_id: r[:session_id],
1928
+ outcome: outcome,
1929
+ details: outcome[:rationale].to_s,
1930
+ tags: %w[requeue],
1931
+ judge_source: outcome[:source]
1884
1932
  )
1933
+ updated = File.readlines(LEARNING_FILE).map do |line|
1934
+ row = JSON.parse(line, symbolize_names: true)
1935
+ row.merge!(status: 'rejudged', rescore_id: fresh[:id]) if row[:id] == r[:id]
1936
+ "#{JSON.generate(row)}\n"
1937
+ end
1938
+ File.write(LEARNING_FILE, updated.join)
1885
1939
  n += 1
1886
1940
  end
1887
1941
  { rescored: n, dry_run: false }
@@ -1915,14 +1969,16 @@ module PWN
1915
1969
  task: 'required - short description of what was attempted',
1916
1970
  success: 'required - Boolean, did the attempt achieve its goal',
1917
1971
  details: 'optional - free-form notes / error / evidence',
1972
+ rationale: 'optional - judge explanation retained for consistency checks',
1918
1973
  session_id: 'optional - PWN::Sessions id this outcome belongs to',
1919
1974
  tags: 'optional - Array of String labels for later retrieval',
1920
1975
  score: 'optional - score value consumed by #note_outcome',
1976
+ outcome: 'optional - canonical Reward outcome; preserves evidence, verdict and training eligibility',
1921
1977
  judge_source: 'required - judge source value consumed by #note_outcome',
1922
1978
  predicted: 'optional - predicted value consumed by #note_outcome',
1923
1979
  confidence: 'optional - confidence value consumed by #note_outcome',
1924
1980
  engine: 'optional - engine value consumed by #note_outcome',
1925
- verifier_verdict: 'optional - pass|fail from a deterministic verifier',
1981
+ verifier_verdict: 'optional - legacy diagnostic flag; cannot prove completion',
1926
1982
  verdict_class: 'optional - missing_artifact|wrong_path|unverified_claim|scope_miss|partial_coverage|style_only',
1927
1983
  remediation_hint: 'optional - one-line fix hint'
1928
1984
  )
@@ -1932,7 +1988,7 @@ module PWN
1932
1988
  limit: 'optional - max entries (defaults to 50)'
1933
1989
  )
1934
1990
 
1935
- # Rescore conflicted outcomes after verifier-precedence lands.
1991
+ # Rejudge conflicted outcomes from the original session; never boost scores blindly.
1936
1992
  #{self}.requeue_conflicted(
1937
1993
  dry_run: 'optional - true to count without writing'
1938
1994
  )
@@ -2081,6 +2137,17 @@ module PWN
2081
2137
  include_demoted: 'optional - include demoted lessons (defaults to false)'
2082
2138
  )
2083
2139
 
2140
+ # Compare structured decisions, never infer verification from prose.
2141
+ #{self}.consistency_check(
2142
+ outcome: 'optional - canonical outcome to compare against success',
2143
+ success: 'required - boolean success flag'
2144
+ )
2145
+
2146
+ # Append a disputed outcome to ~/.pwn/learning/disputed.jsonl.
2147
+ #{self}.disputed_save(
2148
+ entry: 'required - Hash of the disputed learning row'
2149
+ )
2150
+
2084
2151
  # Print the AUTHOR(S) string for this module.
2085
2152
  #{self}.authors
2086
2153
  "
@@ -171,7 +171,7 @@ module PWN
171
171
  return unless debug_on?(opts)
172
172
  return unless defined?(PWN::Plugins::Log)
173
173
 
174
- if defined?(TurnFinalizer) && TurnFinalizer.user_path?
174
+ if opts[:nested] || (defined?(TurnFinalizer) && TurnFinalizer.user_path?)
175
175
  debug_progress(msg: 'nested Loop.run skip_roll', debug: opts[:debug])
176
176
  return
177
177
  end
@@ -704,7 +704,7 @@ module PWN
704
704
  request = opts[:request].to_s
705
705
  unmet = []
706
706
  files = Array(contract[:paths]) + Array(contract[:proofs])
707
- files.each { |path| unmet << "file_exists:#{path}" if deliverable_missing?(path: path) }
707
+ files.each { |path| unmet << "deliverable_missing:#{path}" if deliverable_missing?(path: path) }
708
708
  trace_files = session_files(messages: opts[:messages])
709
709
  unmet << 'issue_work_proofs' if contract[:issue_work] && Array(contract[:proofs]).empty? && files.empty? && trace_files.empty?
710
710
  unmet << 'skills' if declared_skills_missing?(skills: contract[:skills], request: request)
@@ -713,6 +713,7 @@ module PWN
713
713
  asked_tech = Array(contract[:techniques]).select { |tech| request.downcase.include?(tech.to_s.downcase) }
714
714
  unmet << 'hosts' if evidence_tokens_missing?(tokens: asked_hosts, blob: blob)
715
715
  unmet << 'techniques' if evidence_tokens_missing?(tokens: asked_tech, blob: blob)
716
+ Array(Swarm.honesty_unmet(swarm_id: Thread.current[:pwn_swarm_id])).each { |u| unmet << u } if defined?(Swarm)
716
717
  unmet
717
718
  rescue StandardError
718
719
  []
@@ -1063,6 +1064,9 @@ module PWN
1063
1064
  end
1064
1065
 
1065
1066
  private_class_method def self.active_engine
1067
+ tl = Thread.current[:pwn_swarm_engine].to_s
1068
+ return tl.downcase.to_sym unless tl.empty?
1069
+
1066
1070
  e = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
1067
1071
  e == :'' ? :openai : e
1068
1072
  rescue StandardError
@@ -1117,9 +1121,16 @@ module PWN
1117
1121
  Metrics.record(name: name, success: sem[:semantic_ok], duration: dur, error: sem[:err], engine: opts[:engine]) if defined?(Metrics)
1118
1122
  # R5 — live MDP step. Hygiene reward only; terminal credit is judge.
1119
1123
  if defined?(PWN::AI::Agent::Policy) && Policy.respond_to?(:observe_step)
1124
+ policy_args = begin
1125
+ opts[:args].is_a?(String) ? JSON.parse(opts[:args]) : opts[:args]
1126
+ rescue JSON::ParserError
1127
+ nil
1128
+ end
1120
1129
  Policy.observe_step(
1121
1130
  session_id: opts[:session_id],
1122
1131
  action: name,
1132
+ args: policy_args,
1133
+ result_type: sem[:shape],
1123
1134
  ok: sem[:semantic_ok],
1124
1135
  duration: dur,
1125
1136
  engine: opts[:engine],
@@ -1176,6 +1187,16 @@ module PWN
1176
1187
  false
1177
1188
  end
1178
1189
 
1190
+ private_class_method def self.session_tool_budget(opts = {})
1191
+ name = opts[:name].to_s
1192
+ result = opts[:result].to_s
1193
+ return 16_384 if name.match?(/decompile|binary_triage|artifact_read|exploitdev/)
1194
+ return 16_384 if name == 'pwn_eval' && result.match?(/r2 |objdump|xxd |pdf\b|afl-/)
1195
+ return 16_384 if name == 'shell' && result.match?(/objdump|xxd|radare|r2 |disassembl/)
1196
+
1197
+ 1_024
1198
+ end
1199
+
1179
1200
  # E1 — did the environment change under this tool? If Metrics CUSUM
1180
1201
  # tripped for it in the last hour AND Extrospection.drift shows a
1181
1202
  # toolchain/net/repo change, blame the WORLD not the AGENT.
@@ -1384,7 +1405,9 @@ module PWN
1384
1405
  hint = Swarm.ask(
1385
1406
  name: persona.to_s,
1386
1407
  request: "Local agent is stuck on: #{request}\nFailed attempts: #{summary}\n" \
1387
- 'Give a 3-line corrective hint (which tool, which args, why). Reply with the hint ONLY.'
1408
+ 'Give a 3-line corrective hint (which tool, which args, why). Reply with the hint ONLY.',
1409
+ swarm_id: Thread.current[:pwn_swarm_id],
1410
+ text_only: true
1388
1411
  )
1389
1412
  reply = hint.is_a?(Hash) ? hint[:reply].to_s : hint.to_s
1390
1413
  Mistakes.record(tool: 'escalation', error: "local stuck after #{turn_fails.values.sum} fails; frontier hint requested", session_id: opts[:session_id], source: :loop) if defined?(Mistakes)
@@ -1692,10 +1715,12 @@ module PWN
1692
1715
 
1693
1716
  mod = Object.const_get(mod_name)
1694
1717
  if mod.respond_to?(:chat_with_tools)
1695
- # xAI/OpenAI reject Hash function.arguments / Hash content (422 map→string).
1718
+ # xAI rejects Hash function.arguments / Hash content (422 map→string).
1719
+ # OpenAI sanitizes inside its provider so native Responses reasoning
1720
+ # survives until the credential-specific transport is selected.
1696
1721
  # Ollama / Open WebUI reject *string* function.arguments (HTTP 400
1697
1722
  # "can't find closing '}' symbol") — opposite of OpenAI wire form.
1698
- wire_msgs = if %i[grok openai].include?(engine)
1723
+ wire_msgs = if engine == :grok
1699
1724
  openai_wire_messages(messages: messages)
1700
1725
  elsif local_engine?(engine: engine)
1701
1726
  ollama_wire_messages(messages: messages)
@@ -2717,7 +2742,7 @@ module PWN
2717
2742
  loud_debug_tui!(debug: opts[:debug])
2718
2743
  debug_progress(msg: "Loop.run start request=#{request[0, 240]}", debug: opts[:debug])
2719
2744
  ToolGuard.reset_timeout_budget! if defined?(ToolGuard) && ToolGuard.respond_to?(:reset_timeout_budget!)
2720
- nested = defined?(TurnFinalizer) && TurnFinalizer.user_path?
2745
+ nested = opts[:nested] == true || Thread.current[:pwn_swarm_depth].to_i.positive? || (defined?(TurnFinalizer) && TurnFinalizer.user_path?)
2721
2746
  TurnFinalizer.enter_user_path! if defined?(TurnFinalizer)
2722
2747
  Thread.current[:pwn_loop_nested] = nested
2723
2748
  bound = operator_bound_refusal(from: opts[:from] || opts[:account])
@@ -2932,6 +2957,14 @@ module PWN
2932
2957
 
2933
2958
  t0 = Time.now
2934
2959
  begin
2960
+ # Observations update the policy's context during execution.
2961
+ # Refresh exposure for the next hop without widening its scope
2962
+ # or substituting a generated goal for the original request.
2963
+ if tools_called.positive?
2964
+ tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: request, core_only: core_only, intent: intent)
2965
+ no_tools = Array(tools).empty?
2966
+ Thread.current[:pwn_loop_no_tools] = no_tools
2967
+ end
2935
2968
  repair_tool_history!(messages: messages)
2936
2969
  msg = call_engine(messages: messages, tools: tools, ts_state: ts_state)
2937
2970
  rescue StandardError => e
@@ -3126,7 +3159,7 @@ module PWN
3126
3159
  append_session(
3127
3160
  session_id: session_id,
3128
3161
  role: 'tool',
3129
- content: "#{name} → #{result[0, 1_024]}"
3162
+ content: "#{name} → #{result[0, session_tool_budget(name: name, result: result)]}"
3130
3163
  )
3131
3164
  end
3132
3165
 
@@ -3151,6 +3184,11 @@ module PWN
3151
3184
  end
3152
3185
  raise
3153
3186
  rescue StandardError => e
3187
+ if defined?(PWN::AI::HttpRetry) && PWN::AI::HttpRetry.quota_exhausted?(error: e)
3188
+ msg = PWN::AI::HttpRetry.quota_message(error: e)
3189
+ debug_progress(msg: "engine quota: #{msg}")
3190
+ return msg
3191
+ end
3154
3192
  if defined?(PWN::Plugins::Log) && PWN::Plugins::Log.respond_to?(:note_exception!)
3155
3193
  PWN::Plugins::Log.note_exception!(error: e, where: 'Loop.run', which_self: self)
3156
3194
  else
@@ -3250,7 +3288,11 @@ module PWN
3250
3288
  debug: 'optional - debug value consumed by #run',
3251
3289
  from: 'optional - sender account or address to bind as operator',
3252
3290
  account: 'optional - operator account id to bind',
3253
- force_tools: 'optional - force tools value consumed by #run'
3291
+ force_tools: 'optional - force tools value consumed by #run',
3292
+ nested: 'optional - true for Swarm/child Loop.run (skip RN footer)',
3293
+ core_only: 'optional - restrict to CORE_TOOLS when true',
3294
+ trace: 'optional - enable TracePoint debug for this run',
3295
+ debug_tee: 'optional - IO to tee debug logs'
3254
3296
  )
3255
3297
 
3256
3298
  # Remaining time/token/mutation budget for the current loop.
@@ -156,6 +156,49 @@ module PWN
156
156
  .sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit)
157
157
  end
158
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
+
172
+ public_class_method def self.record_tokens(opts = {})
173
+ n = opts[:tokens].to_i
174
+ cost = opts[:cost].to_f
175
+ model = opts[:model].to_s
176
+ m = load
177
+ m[:usage] ||= { tokens: 0, cost: 0.0, calls: 0, by_model: {} }
178
+ m[:usage][:tokens] += n
179
+ m[:usage][:cost] += cost
180
+ m[:usage][:calls] += 1
181
+ m[:usage][:by_model][model] ||= { tokens: 0, cost: 0.0, calls: 0 }
182
+ m[:usage][:by_model][model][:tokens] += n
183
+ m[:usage][:by_model][model][:cost] += cost
184
+ m[:usage][:by_model][model][:calls] += 1
185
+ save(metrics: m)
186
+ m[:usage]
187
+ end
188
+
189
+ public_class_method def self.usage(opts = {})
190
+ _sid = opts[:session_id]
191
+ load[:usage] || { tokens: 0, cost: 0.0, calls: 0 }
192
+ end
193
+
194
+ public_class_method def self.routing(opts = {})
195
+ _n = opts[:n]
196
+ chain = (PWN::Env.dig(:ai, :routing) if defined?(PWN::Env))
197
+ Array(chain)
198
+ rescue StandardError
199
+ []
200
+ end
201
+
159
202
  # Supported Method Parameters::
160
203
  # ctx = PWN::AI::Agent::Metrics.to_context(
161
204
  # limit: 'optional - cap number of tools included (default 8)',
@@ -544,9 +587,12 @@ module PWN
544
587
  end
545
588
  task_ok = nil
546
589
  if defined?(Learning) && Learning.respond_to?(:outcomes)
547
- rec = Learning.outcomes(limit: 200)
590
+ rec = Learning.outcomes(limit: 200).reject do |r|
591
+ r[:success].nil? || r[:status].to_s == 'unverified' || r[:verdict].to_s == 'unknown' ||
592
+ (r[:decision_version] && r[:training_score].nil?)
593
+ end
548
594
  if rec.any?
549
- hits = rec.count { |r| r[:success] == true || r[:score].to_f >= 0.6 }
595
+ hits = rec.count { |r| r[:success] == true }
550
596
  task_ok = (hits.to_f / rec.length).round(3)
551
597
  end
552
598
  end
@@ -814,6 +860,28 @@ module PWN
814
860
  # Run reset and return its result
815
861
  #{self}.reset
816
862
 
863
+ # Snapshot success rates per judge source for the LEARNING block.
864
+ #{self}.snapshot(
865
+ day: 'optional - reserved day key for rotation'
866
+ )
867
+
868
+ # Record token/cost for a model call.
869
+ #{self}.record_tokens(
870
+ tokens: 'optional - integer token count',
871
+ cost: 'optional - Float USD cost',
872
+ model: 'optional - model id string'
873
+ )
874
+
875
+ # Return cumulative token/cost usage.
876
+ #{self}.usage(
877
+ session_id: 'optional - unused reserved session id'
878
+ )
879
+
880
+ # Return ai.routing fallback chain from pwn.yaml.
881
+ #{self}.routing(
882
+ n: 'optional - unused reserved index'
883
+ )
884
+
817
885
  # Print the AUTHOR(S) string for this module.
818
886
  #{self}.authors
819
887
  "
@@ -127,6 +127,31 @@ module PWN
127
127
  'other'
128
128
  end
129
129
 
130
+ public_class_method def self.family(opts = {})
131
+ klass = error_class(opts)
132
+ case klass
133
+ when 'docker_registry_auth', 'auth_denied' then 'auth_denied'
134
+ when 'name_conflict' then 'name_conflict'
135
+ when 'parse_error' then 'parse_error'
136
+ when 'missing_path', 'enoent' then 'missing_path'
137
+ when 'perm_denied', 'socket_perm', 'eacces' then 'permission_capability'
138
+ when 'net_unreach' then 'network_unreachable'
139
+ when 'timeout' then 'timeout'
140
+ else 'other'
141
+ end
142
+ end
143
+
144
+ FAMILY_FIXES = {
145
+ 'auth_denied' => 'check registry credentials / image name; do NOT ls paths',
146
+ 'name_conflict' => 'rename the resource or remove the existing name; do NOT ls paths',
147
+ 'parse_error' => 'fix template/JSON syntax; do NOT ls paths',
148
+ 'missing_path' => 'Path missing. ls/test -e the parent first, then run. Do not retry the same missing path.',
149
+ 'permission_capability' => 'Raw sockets need CAP_NET_RAW. Do not retry open_sockraw. Use connect-scan or Sock.',
150
+ 'network_unreachable' => 'network unreachable; check routing/VPN, do not ls paths',
151
+ 'timeout' => 'deadline too short; retry same payload with timeout += 180',
152
+ 'other' => 'inspect the exact error class before retrying'
153
+ }.freeze
154
+
130
155
  # Supported Method Parameters::
131
156
  # entry = PWN::AI::Agent::Mistakes.find(
132
157
  # signature: 'optional - exact signature to fetch',
@@ -630,10 +655,38 @@ module PWN
630
655
 
631
656
  parts = ["seen #{m[:count]}× across #{Array(m[:sessions]).length} session(s), sig=#{m[:signature]}"]
632
657
  parts << 'REGRESSED (previous fix did not hold)' if m[:regressed]
633
- parts << "KNOWN FIX: #{m[:fix]}" if m[:fix].to_s.strip.length.positive?
658
+ fam = family(error: opts[:error] || m[:error])
659
+ fam_fix = FAMILY_FIXES[fam]
660
+ stored = m[:fix].to_s.strip
661
+ stored = '' if stored.match?(%r{ls/test -e the parent}) && fam != 'missing_path'
662
+ stored = '' if m[:hint_confidence].to_f.negative?
663
+ stored = '' if stored.include?('[UNVERIFIED]')
664
+ hint = stored.empty? ? fam_fix : stored
665
+ parts << "KNOWN FIX: #{hint}" unless hint.to_s.empty?
634
666
  "[pwn-ai/mistakes] #{parts.join(' | ')}"
635
667
  end
636
668
 
669
+ public_class_method def self.note_hint_outcome(opts = {})
670
+ sig = (opts[:signature] || signature(tool: opts[:tool], error: opts[:error])).to_s
671
+ return nil if sig.empty?
672
+
673
+ store = load
674
+ m = store[sig.to_sym]
675
+ return nil unless m
676
+
677
+ m[:hint_outcomes] = m[:hint_outcomes].to_i + 1
678
+ unless opts[:helped]
679
+ m[:hint_misses] = m[:hint_misses].to_i + 1
680
+ if m[:hint_misses].to_i >= 3
681
+ m[:fix] = "[UNVERIFIED] #{m[:fix]}"
682
+ m[:hint_confidence] = -1.0
683
+ end
684
+ end
685
+ store[sig.to_sym] = m
686
+ save(store: store)
687
+ m
688
+ end
689
+
637
690
  # Supported Method Parameters::
638
691
  # bool = PWN::AI::Agent::Mistakes.correction?(request: user_text)
639
692
 
@@ -988,6 +1041,19 @@ module PWN
988
1041
  signature: 'optional - signature value consumed by #effective_count'
989
1042
  )
990
1043
 
1044
+ # Map an error string onto a coarse family (auth vs path vs parse).
1045
+ #{self}.family(
1046
+ error: 'required - raw error text'
1047
+ )
1048
+
1049
+ # Record whether an injected KNOWN FIX helped; demote after 3 misses.
1050
+ #{self}.note_hint_outcome(
1051
+ tool: 'optional - tool name',
1052
+ error: 'optional - raw error text',
1053
+ signature: 'optional - explicit signature',
1054
+ helped: 'optional - true when the hint resolved the failure'
1055
+ )
1056
+
991
1057
  # Print the AUTHOR(S) string for this module.
992
1058
  #{self}.authors
993
1059
  "