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
@@ -44,11 +44,20 @@ module PWN
44
44
  MAX_TRAJ = 2_000
45
45
  GOLD_MIN = 0.6
46
46
  VISITS_MIN = 2
47
+ CONTEXT_VISITS_MIN = 3
47
48
  COLD_EPISODES = 8
48
49
  WARM_EPISODES = 40
49
50
  TASK_MOD = 16
50
51
  ACTION_MOD = 16
51
52
  EP_KEY = :pwn_policy_episode
53
+ OPERATIONS = %w[read list search inspect write create update delete execute start stop status poll].freeze
54
+ RESULT_TYPES = %w[success failure timeout enoent eacces auth_required network syntax exit127 exit126 nonzero_exit handler_error invalid_payload].freeze
55
+ ARGUMENT_ROLES = {
56
+ 'action' => 'operation', 'operation' => 'operation', 'op' => 'operation',
57
+ 'path' => 'path', 'file' => 'path', 'directory' => 'path',
58
+ 'command' => 'program', 'code' => 'program', 'query' => 'query',
59
+ 'url' => 'url', 'timeout' => 'control', 'limit' => 'control'
60
+ }.freeze
52
61
 
53
62
  # ----------------------------------------------------------------
54
63
  # Feature → discrete state
@@ -110,7 +119,7 @@ module PWN
110
119
  tab[:q].each_value { |acts| pairs += acts.length if acts.is_a?(Hash) }
111
120
  return true if warmed && pairs >= COLD_EPISODES
112
121
 
113
- traj_n = trajectories(limit: COLD_EPISODES).length
122
+ traj_n = trajectories(limit: COLD_EPISODES).count { |ep| !ep[:score].nil? }
114
123
  warmed && traj_n >= COLD_EPISODES
115
124
  rescue StandardError
116
125
  false
@@ -145,7 +154,7 @@ module PWN
145
154
  )
146
155
  ep = {
147
156
  session_id: sid,
148
- request: opts[:request].to_s[0, 240],
157
+ request: opts[:request].to_s.dup,
149
158
  kind: normalize_kind(raw: opts[:kind]),
150
159
  intent: opts[:intent].to_s,
151
160
  engine: opts[:engine].to_s,
@@ -168,6 +177,9 @@ module PWN
168
177
  # step = PWN::AI::Agent::Policy.observe_step(
169
178
  # session_id: 'optional - must match begin_episode when set',
170
179
  # action: 'required - tool name',
180
+ # args: 'optional - arguments, reduced to fixed roles/types, never stored raw',
181
+ # operation: 'optional - known operation override; otherwise derived from args',
182
+ # result_type: 'optional - Reward.semantic_ok shape; fixed allowlist only',
171
183
  # ok: 'required - Boolean, Reward.semantic_ok',
172
184
  # duration: 'optional - Float seconds',
173
185
  # ts_state: 'optional - TaskSummarizer state',
@@ -211,9 +223,14 @@ module PWN
211
223
  engine: ep[:engine],
212
224
  ts_state: opts[:ts_state]
213
225
  )
226
+ context = action_context(opts)
227
+ context_s2 = contextual_state(state: s2, action: action, context: context)
214
228
  trans = {
215
229
  state: s,
216
230
  action: action,
231
+ action_context: context,
232
+ context_state: ep[:context_state],
233
+ next_context_state: context_s2,
217
234
  reward: reward,
218
235
  next_state: s2,
219
236
  ok: ok,
@@ -222,6 +239,7 @@ module PWN
222
239
  }
223
240
  ep[:steps] << trans
224
241
  ep[:state] = s2
242
+ ep[:context_state] = context_s2
225
243
  ep[:last_action] = action
226
244
  trans
227
245
  rescue StandardError => e
@@ -278,15 +296,19 @@ module PWN
278
296
  ep[:steps].last[:terminal] = true
279
297
  ep[:score] = opts[:score]
280
298
  ep[:verdict] = opts[:verdict]
281
- ep[:return] = discounted_return(steps: ep[:steps])
299
+ ep[:return] = opts[:score].nil? ? nil : discounted_return(steps: ep[:steps])
282
300
  ep[:ended_at] = Time.now.utc.iso8601
283
301
 
284
302
  n_td = 0
285
303
  n_pg = 0
286
304
  ep[:steps].each_with_index do |tr, idx|
287
- n_td += 1 if update_q!(transition: tr)
305
+ next if opts[:score].nil?
306
+
288
307
  g = discounted_return(steps: ep[:steps][idx..])
289
- n_pg += 1 if update_pg!(state: tr[:state], action: tr[:action], advantage: g - value(state: tr[:state]))
308
+ transition_variants(transition: tr).each do |variant|
309
+ n_td += 1 if update_q!(transition: variant)
310
+ n_pg += 1 if update_pg!(state: variant[:state], action: variant[:action], advantage: g - value(state: variant[:state]))
311
+ end
290
312
  end
291
313
 
292
314
  persist_episode!(episode: ep)
@@ -401,11 +423,16 @@ module PWN
401
423
 
402
424
  tab = load
403
425
  visits = read_visit(table: tab, state: s, action: a)
404
- qsa = smoothed_q(table: tab, state: s, action: a)
426
+ context = opts[:context_state]
427
+ context = nil unless context.to_s.start_with?("#{s}~ctx:")
428
+ contextual_visits = read_visit(table: tab, state: context, action: a)
429
+ qsa = routing_q(table: tab, state: s, context_state: context, action: a)
405
430
  # Tiny visit counts stay at 0 unless the value is already decisive.
406
- return 0.0 if visits < VISITS_MIN && qsa.abs < 0.08
431
+ return 0.0 if contextual_visits < CONTEXT_VISITS_MIN && visits < VISITS_MIN && qsa.abs < 0.08
407
432
 
408
- (qsa - max_q(table: tab, state: s)).round(4)
433
+ actions = ((tab[:q][s.to_s.to_sym] || {}).keys + (tab[:q][context.to_s.to_sym] || {}).keys).uniq
434
+ baseline = actions.map { |act| routing_q(table: tab, state: s, context_state: context, action: act) }.max || 0.0
435
+ (qsa - baseline).clamp(-1.0, 1.0).round(4)
409
436
  rescue StandardError
410
437
  0.0
411
438
  end
@@ -432,7 +459,8 @@ module PWN
432
459
  return { action: actions.sample, reason: :explore, state: s, epsilon: eps } if rand < eps
433
460
 
434
461
  tab = load
435
- scored = actions.map { |a| [a, smoothed_q(table: tab, state: s, action: a)] }
462
+ context = opts.key?(:context_state) ? opts[:context_state] : (current_context_state if s == current_state)
463
+ scored = actions.map { |a| [a, routing_q(table: tab, state: s, context_state: context, action: a)] }
436
464
  best = scored.max_by { |_, v| v }
437
465
  { action: best[0], q: best[1].round(4), reason: :greedy, state: s, ranked: scored.sort_by { |_, v| -v } }
438
466
  rescue StandardError => e
@@ -448,6 +476,13 @@ module PWN
448
476
  Thread.current[EP_KEY]
449
477
  end
450
478
 
479
+ # Previous action features are available BEFORE the next tool choice;
480
+ # the candidate's own result must never leak into its decision state.
481
+ public_class_method def self.current_context_state
482
+ ep = current_episode
483
+ ep.is_a?(Hash) ? ep[:context_state] : nil
484
+ end
485
+
451
486
  # Hermes split: snapshot + clear the live episode so Loop.maybe_finish_policy
452
487
  # is a no-op on the user-visible path while TurnFinalizer re-attaches it
453
488
  # on the background review thread.
@@ -538,7 +573,7 @@ module PWN
538
573
  # Does not write. Used by task 7 (evaluate policy quality).
539
574
 
540
575
  public_class_method def self.evaluate(opts = {})
541
- rows = trajectories(limit: opts[:limit] || 200)
576
+ rows = trajectories(limit: opts[:limit] || 200).reject { |ep| ep[:score].nil? }
542
577
  return { n: 0, mean_return: nil, greedy_match: nil, mean_abs_td: nil } if rows.empty?
543
578
 
544
579
  tab = load
@@ -705,6 +740,9 @@ module PWN
705
740
  #{self}.observe_step(
706
741
  session_id: 'optional - must match begin_episode when set',
707
742
  action: 'required - tool name',
743
+ args: 'optional - arguments reduced to fixed roles/types only',
744
+ operation: 'optional - allowlisted operation override',
745
+ result_type: 'optional - allowlisted semantic result shape',
708
746
  ok: 'required - Boolean, Reward.semantic_ok',
709
747
  duration: 'optional - Float seconds',
710
748
  ts_state: 'optional - TaskSummarizer state',
@@ -750,13 +788,15 @@ module PWN
750
788
  # Q(s,a) − V(s). Unknown / cold-start pairs return 0 so rank is unchanged
751
789
  #{self}.advantage(
752
790
  state: 'optional - state value consumed by #advantage (defaults to current_state)',
753
- action: 'optional - action value consumed by #advantage'
791
+ action: 'optional - action value consumed by #advantage',
792
+ context_state: 'optional - previous action context supplied by Registry'
754
793
  )
755
794
 
756
795
  # Run recommend and return its result
757
796
  #{self}.recommend(
758
797
  state: 'optional - default current episode state',
759
798
  actions: 'required - Array of tool names',
799
+ context_state: 'optional - previous action context (default live context)',
760
800
  epsilon: 'optional - explore probability (default EPSILON)'
761
801
  )
762
802
 
@@ -766,6 +806,9 @@ module PWN
766
806
  # Run current episode and return its result
767
807
  #{self}.current_episode
768
808
 
809
+ # Sanitized previous-action state used for advisory routing
810
+ #{self}.current_context_state
811
+
769
812
  # Hermes split: snapshot + clear the live episode so Loop.maybe_finish_policy
770
813
  #{self}.detach_episode!
771
814
 
@@ -832,6 +875,74 @@ module PWN
832
875
  { q: {}, h: {}, visits: {}, returns: [], n_updates: 0, td_abs_sum: 0.0, updated_at: nil }
833
876
  end
834
877
 
878
+ private_class_method def self.contextual_state(opts = {})
879
+ "#{opts[:state]}~ctx:a#{action_bucket(name: opts[:action])}:#{JSON.generate(opts[:context])}"
880
+ end
881
+
882
+ private_class_method def self.transition_variants(opts = {})
883
+ tr = opts[:transition]
884
+ return [tr] if tr[:context_state].to_s.empty?
885
+
886
+ [tr, tr.merge(state: tr[:context_state], next_state: tr[:next_context_state])]
887
+ end
888
+
889
+ # Shrink sparse contextual values toward the existing broad Q table.
890
+ # A strict sample floor prevents a single unusual call moving rank.
891
+ private_class_method def self.routing_q(opts = {})
892
+ broad = smoothed_q(table: opts[:table], state: opts[:state], action: opts[:action])
893
+ context = opts[:context_state]
894
+ return broad unless context.to_s.start_with?("#{opts[:state]}~ctx:")
895
+
896
+ n = read_visit(table: opts[:table], state: context, action: opts[:action])
897
+ return broad if n < CONTEXT_VISITS_MIN
898
+
899
+ specific = read_q(table: opts[:table], state: context, action: opts[:action])
900
+ weight = n.to_f / (n + CONTEXT_VISITS_MIN)
901
+ ((weight * specific) + ((1.0 - weight) * broad)).round(5)
902
+ end
903
+
904
+ # No arbitrary keys, values, lengths, hashes, paths, or class names.
905
+ # Nested containers are represented by type only, not traversed.
906
+ private_class_method def self.action_context(opts = {})
907
+ args = opts[:args]
908
+ operation = opts[:operation]
909
+ operation = args[:operation] || args['operation'] || args[:action] || args['action'] || args[:op] || args['op'] if operation.nil? && args.is_a?(Hash)
910
+ operation ||= 'execute' if %w[shell pwn_eval].include?(opts[:action].to_s)
911
+ operation = operation.to_s.downcase
912
+ operation = 'other' unless OPERATIONS.include?(operation)
913
+ arguments = { shape: feature_type(value: args) }
914
+ if args.is_a?(Hash)
915
+ size = if args.empty?
916
+ 'empty'
917
+ elsif args.length <= 4
918
+ 'few'
919
+ else
920
+ 'many'
921
+ end
922
+ features = args.map do |key, value|
923
+ role = ARGUMENT_ROLES.fetch(key.to_s, 'other')
924
+ "#{role}:#{feature_type(value: value)}"
925
+ end.uniq.sort
926
+ arguments.merge!(size: size, features: features)
927
+ end
928
+ result = opts[:result_type].to_s
929
+ result = opts[:ok] ? 'success' : 'failure' if result.empty?
930
+ result = 'other' unless RESULT_TYPES.include?(result)
931
+ { operation: operation, arguments: arguments, result_type: result }
932
+ end
933
+
934
+ private_class_method def self.feature_type(opts = {})
935
+ case opts[:value]
936
+ when Hash then 'object'
937
+ when Array then 'array'
938
+ when String, Symbol then 'string'
939
+ when Numeric then 'number'
940
+ when true, false then 'boolean'
941
+ when nil then 'null'
942
+ else 'other'
943
+ end
944
+ end
945
+
835
946
  private_class_method def self.normalize_kind(opts = {})
836
947
  raw = opts[:raw].to_s.downcase
837
948
  return 'goal' if raw.empty?
@@ -1073,6 +1184,7 @@ module PWN
1073
1184
  vals = []
1074
1185
  tab[:q].each do |key, acts|
1075
1186
  next unless acts.is_a?(Hash) && acts.key?(act)
1187
+ next if key.to_s.include?('~ctx:')
1076
1188
 
1077
1189
  ks = key.to_s.split('|')
1078
1190
  next unless ks[0] == kind && ks[1] == task && ks[-1] == eng
@@ -1092,11 +1204,19 @@ module PWN
1092
1204
  return { skipped: :no_traj } unless File.exist?(TRAJECTORY_FILE)
1093
1205
 
1094
1206
  tab = load
1095
- rows = trajectories(limit: opts[:limit] || 400)
1207
+ rows = trajectories(limit: opts[:limit] || 400).reject { |ep| ep[:score].nil? }
1208
+ context_counts = Hash.new(0)
1209
+ rows.each do |ep|
1210
+ Array(ep[:steps]).each do |tr|
1211
+ next if tr[:context_state].to_s.empty? || tr[:action].to_s.empty?
1212
+
1213
+ context_counts[[tr[:context_state].to_s.to_sym, tr[:action].to_s.to_sym]] += 1
1214
+ end
1215
+ end
1096
1216
  n = 0
1097
1217
  2.times do
1098
1218
  rows.reverse_each do |ep|
1099
- Array(ep[:steps]).each do |tr|
1219
+ Array(ep[:steps]).flat_map { |tr| transition_variants(transition: tr) }.each do |tr|
1100
1220
  s = tr[:state].to_s
1101
1221
  a = tr[:action].to_s
1102
1222
  next if s.empty? || a.empty?
@@ -1109,13 +1229,19 @@ module PWN
1109
1229
  target = r + (GAMMA * max_n)
1110
1230
  td = target - qsa
1111
1231
  write_q!(table: tab, state: s, action: a, value: qsa + (ALPHA * td))
1112
- bump_visit!(table: tab, state: s, action: a)
1232
+ bump_visit!(table: tab, state: s, action: a) unless s.include?('~ctx:')
1113
1233
  tab[:n_updates] = tab[:n_updates].to_i + 1
1114
1234
  tab[:td_abs_sum] = tab[:td_abs_sum].to_f + td.abs
1115
1235
  n += 1
1116
1236
  end
1117
1237
  end
1118
1238
  end
1239
+ # Replay is not new evidence: restore observed counts, never count
1240
+ # the two passes (or repeated warmups) as independent samples.
1241
+ context_counts.each do |(s, a), count|
1242
+ tab[:visits][s] ||= {}
1243
+ tab[:visits][s][a] = [tab[:visits][s][a].to_i, count].max
1244
+ end
1119
1245
  # Credit stored returns toward the episode budget so greedy
1120
1246
  # suggestions are not omitted after a successful replay of a
1121
1247
  # table that never finished COLD_EPISODES live turns.
@@ -1151,12 +1277,12 @@ module PWN
1151
1277
  return unless ep.is_a?(Hash)
1152
1278
 
1153
1279
  tab = load
1154
- tab[:returns] = (Array(tab[:returns]) + [ep[:return].to_f]).last(200)
1280
+ tab[:returns] = (Array(tab[:returns]) + [ep[:return].to_f]).last(200) unless ep[:score].nil?
1155
1281
  save(table: tab)
1156
1282
  FileUtils.mkdir_p(File.dirname(TRAJECTORY_FILE))
1157
1283
  row = {
1158
1284
  session_id: ep[:session_id],
1159
- request: ep[:request],
1285
+ request_family: task_family(text: ep[:request]),
1160
1286
  kind: ep[:kind],
1161
1287
  engine: ep[:engine],
1162
1288
  started_at: ep[:started_at],
@@ -276,7 +276,7 @@ module PWN
276
276
  ucb = defined?(Metrics) && Metrics.respond_to?(:ucb) ? Metrics.ucb(name: e.name) : 0.5
277
277
  prm = defined?(Metrics) && Metrics.respond_to?(:prm_advantage) ? Metrics.prm_advantage(name: e.name) : 0.0
278
278
  qadv = if eps_q.positive? && pol_state
279
- Policy.advantage(state: pol_state, action: e.name)
279
+ Policy.advantage(state: pol_state, action: e.name, context_state: Policy.current_context_state)
280
280
  else
281
281
  0.0
282
282
  end
@@ -143,21 +143,26 @@ module PWN
143
143
  # confidence handles that in the bandit blend.
144
144
  eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase
145
145
  local = eng == 'ollama' || eng.empty?
146
- if v[:source].to_s == 'heuristic'
147
- v[:confidence] = local ? 0.35 : 0.5
146
+ if v[:source].to_s == 'heuristic' || v[:source].to_s.start_with?('heuristic')
147
+ toolbacked = Array(trace).any?
148
+ v[:heuristic_class] = toolbacked ? :toolbacked : :textual
149
+ v[:source] = :heuristic
150
+ v[:confidence] = if toolbacked
151
+ local ? 0.55 : 0.7
152
+ else
153
+ local ? 0.35 : 0.5
154
+ end
148
155
  raw = v[:score].to_f
149
156
  v[:score_raw] = raw
150
- # preserve empty / failure-language / polite floors exactly (raw <= 0.15)
151
- # and pass-through non-capped heuristics via the default arm.
152
- if local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
157
+ if !toolbacked && local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
153
158
  v[:score] = [raw, 0.45].min
154
159
  v[:rationale] = "#{v[:rationale]} | P1:local_no_trace_cap"
155
- elsif local && raw > 0.15 && trace.length < 2 && raw >= 0.85
156
- # thin-evidence local highs: mild shrink toward 0.5
160
+ elsif !toolbacked && local && raw > 0.15 && trace.length < 2 && raw >= 0.85
157
161
  v[:score] = (0.5 + ((raw - 0.5) * 0.7)).round(3).clamp(0.0, 1.0)
158
162
  else
159
163
  v[:score] = raw
160
164
  end
165
+
161
166
  v[:verdict] = if v[:score] >= 0.6 then :solved
162
167
  elsif v[:score] >= 0.3 then :partial
163
168
  else :wrong
@@ -168,59 +173,33 @@ module PWN
168
173
 
169
174
  ground = verify_as_reward(final: final)
170
175
  unless ground.nil?
171
- # Ground-truth override: a browser-refuted claim caps score at
172
- # 0.2 regardless of how confident the judge was; a confirmed
173
- # claim floors it at 0.6. E3.
176
+ # A refuted claim is negative evidence. Confirming one claim
177
+ # does not establish completion of the entire operator request.
174
178
  v[:score] = [v[:score], 0.2].min if ground[:verdict] == :refuted
175
- v[:score] = [v[:score], 0.6].max if ground[:verdict] == :confirmed
176
179
  v[:grounded] = ground
177
180
  v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
178
181
  end
179
182
 
180
- pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
181
183
  v[:judge_score] = v[:score].to_f
182
- vv = opts[:verifier_verdict]
183
- vv = :pass if vv.nil? && (opts[:verifier_pass] == true || pass || (ground && ground[:verdict] == :confirmed))
184
- vv = vv.to_s.to_sym if vv
185
- v[:verifier_verdict] = vv
186
- v[:verdict_class] = taxonomy_class(opts.merge(score: v[:score], verifier_verdict: vv, request: request, final: final))
184
+ v[:verification] = request_verification(request: request, session_id: opts[:session_id])
185
+ v = resolve_outcome(outcome: v, critic_pass: opts[:critic_pass])
186
+ v[:verdict_class] = taxonomy_class(opts.merge(score: v[:score], verifier_verdict: v[:verifier_verdict], request: request, final: final))
187
187
  v[:remediation_hint] = taxonomy_hint(verdict_class: v[:verdict_class])
188
- prec = verifier_precedence?
189
- if prec && vv == :pass
190
- v[:success] = true
191
- else
192
- v[:success] = promote_to_success?(
193
- orm: v[:source].to_s != 'heuristic' && v[:score].to_f >= 0.6,
194
- verify: if ground.nil?
195
- nil
196
- else
197
- ground[:verdict] == :confirmed
198
- end,
199
- critic: opts.key?(:critic_pass) ? opts[:critic_pass] : nil
200
- )
201
- end
202
188
  v[:needs_spot_check] = v[:success] && v[:score].to_f >= 0.85 && (rand < 0.05)
203
189
  v[:engine] = eng
204
190
  v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
205
- if pass
206
- score = v[:score].to_f
207
- v[:score] = [score, 0.6].max
208
- v[:success] = true if prec
209
- v[:verifier_verdict] ||= :pass
210
- v[:verdict] = :solved
211
- end
212
191
  v[:score_components] ||= {
213
192
  judge: v[:score].to_f,
214
- overlap: pass ? 0.0 : nil,
193
+ overlap: nil,
215
194
  checks: 0.0,
216
- weights: { overlap: pass ? 0.0 : 0.15 }
195
+ weights: { overlap: 0.15 }
217
196
  }
218
- v[:score_components][:weights][:overlap] = 0.0 if pass
219
197
  if commit && defined?(Learning) && opts[:persist_components]
220
198
  Learning.note_outcome(
221
199
  task: request[0, 80],
222
200
  success: v[:success],
223
201
  score: v[:score],
202
+ outcome: v,
224
203
  details: v[:score_components].to_json,
225
204
  verifier_verdict: v[:verifier_verdict],
226
205
  verdict_class: v[:verdict_class]
@@ -228,7 +207,7 @@ module PWN
228
207
  end
229
208
  # W3 — write Brier on every judged turn so overconfidence can
230
209
  # throttle max_iters/critic even when plan_first never fired.
231
- if commit
210
+ if commit && !v[:training_score].nil?
232
211
  pred = opts[:predicted]
233
212
  pred = Thread.current[:pwn_plan_predicted] if pred.nil?
234
213
  pred = v[:confidence] if pred.nil?
@@ -236,10 +215,103 @@ module PWN
236
215
  end
237
216
  # P1 — sentinel stores confidence so distrust math can haircut
238
217
  # heuristic-heavy windows differently from LLM ORM windows.
239
- record_sentinel(proxy: opts[:proxy_ok], judge: v[:score], confidence: v[:confidence], source: v[:source]) if commit
218
+ record_sentinel(v.slice(:training_score, :decision_version, :verdict, :confidence, :source).merge(proxy: opts[:proxy_ok], judge: v[:training_score])) if commit && !v[:training_score].nil?
240
219
  v
241
220
  rescue StandardError => e
242
- { score: 0.5, verdict: :unknown, rationale: "judge error: #{e.class}", success: !final.strip.empty?, error: e.message, confidence: 0.2, source: :error }
221
+ resolve_outcome(outcome: { score: nil, rationale: "judge error: #{e.class}", error: e.message, confidence: 0.0, source: :error })
222
+ end
223
+
224
+ # The sole outcome decision. Scores are diagnostic; training_score
225
+ # is absent when the evaluator cannot supply a reliable label.
226
+ public_class_method def self.resolve_outcome(opts = {})
227
+ v = (opts[:outcome] || {}).dup
228
+ source = (v[:source] || v[:judge_source]).to_s
229
+ score = v[:score]
230
+ score = score.to_f.clamp(0.0, 1.0) unless score.nil?
231
+ verification = v[:verification]
232
+ checked = verification.is_a?(Hash) && valid_verification_checks?(checks: verification[:checks])
233
+ vv = if checked
234
+ verification[:checks].all? { |c| c[:passed] } ? :pass : :fail
235
+ end
236
+ v[:verifier_verdict] = vv
237
+ v[:confidence] = 1.0 if vv
238
+ if vv == :fail || v.dig(:grounded, :verdict).to_s == 'refuted'
239
+ score = [score || 0.0, 0.2].min
240
+ success = false
241
+ elsif vv == :pass && verifier_precedence?
242
+ score = [score || 0.0, 0.6].max
243
+ success = true
244
+ elsif score.nil? || source == 'error'
245
+ success = nil
246
+ elsif source.start_with?('heuristic')
247
+ success = false
248
+ elsif opts[:critic_pass] == false || v[:critic_pass] == false
249
+ if score >= 0.6
250
+ success = nil
251
+ else
252
+ score = [score, 0.3].min
253
+ success = false
254
+ end
255
+ else
256
+ success = score >= 0.6
257
+ end
258
+ known = !success.nil? && (!source.start_with?('heuristic') || !vv.nil?)
259
+ verdict = if !known then :unknown
260
+ elsif success then :solved
261
+ elsif score >= 0.3 then :partial
262
+ else :wrong
263
+ end
264
+ v.merge(score: score, success: success, verdict: verdict,
265
+ critic_pass: opts.fetch(:critic_pass, v[:critic_pass]),
266
+ training_score: known ? score : nil, decision_version: 1)
267
+ end
268
+
269
+ # Trusted host-verifier API, deliberately NOT a model-facing tool.
270
+ # Caller must actually check every original-request criterion. Never
271
+ # construct these records by parsing an assistant/tool claim of PASS.
272
+ public_class_method def self.record_verification(opts = {})
273
+ request = opts[:request].to_s
274
+ sid = opts[:session_id].to_s
275
+ checks = opts[:checks]
276
+ raise ArgumentError, 'nonempty complete request checks required' unless valid_verification_checks?(checks: checks)
277
+
278
+ rows = PWN::Sessions.load(session_id: sid)
279
+ user = rows.reverse.find { |row| row[:role].to_s == 'user' }
280
+ raise ArgumentError, 'verification must match the current session request' if request.empty? || !user || user[:content].to_s != request
281
+
282
+ record = { request_digest: Digest::SHA256.hexdigest(request), session_id: sid, checks: checks }
283
+ PWN::Sessions.append(session_id: sid, role: 'verification', content: JSON.generate(record))
284
+ record
285
+ end
286
+
287
+ private_class_method def self.valid_verification_checks?(opts = {})
288
+ checks = opts[:checks]
289
+ checks.is_a?(Array) && !checks.empty? && checks.all? do |check|
290
+ check.is_a?(Hash) && !check[:criterion].to_s.strip.empty? &&
291
+ [true, false].include?(check[:passed]) && !check[:evidence].to_s.strip.empty?
292
+ end
293
+ end
294
+
295
+ private_class_method def self.request_verification(opts = {})
296
+ sid = opts[:session_id].to_s
297
+ return nil if sid.empty?
298
+
299
+ rows = PWN::Sessions.load(session_id: sid)
300
+ user_idx = rows.rindex { |row| row[:role].to_s == 'user' }
301
+ return nil unless user_idx && rows[user_idx][:content].to_s == opts[:request].to_s
302
+
303
+ verification_idx = rows.rindex { |entry| entry[:role].to_s == 'verification' }
304
+ return nil unless verification_idx && verification_idx > user_idx
305
+ return nil if rows[(verification_idx + 1)..].any? { |entry| entry[:role].to_s == 'tool' }
306
+
307
+ row = rows[verification_idx]
308
+ record = JSON.parse(row[:content].to_s, symbolize_names: true)
309
+ return nil unless record[:session_id] == sid && record[:request_digest] == Digest::SHA256.hexdigest(opts[:request].to_s)
310
+ return nil unless valid_verification_checks?(checks: record[:checks])
311
+
312
+ record
313
+ rescue StandardError
314
+ nil
243
315
  end
244
316
 
245
317
  public_class_method def self.promote_to_success?(opts = {})
@@ -505,7 +577,7 @@ module PWN
505
577
  need = SENTINEL_WINDOW - have
506
578
  limit = (opts[:limit] || [need * 4, 200].max).to_i
507
579
  # Prefer scored rows; fall back to success-boolean so local hosts still warm.
508
- rows = Learning.outcomes(limit: limit)
580
+ rows = Learning.outcomes(limit: limit).select { |r| sentinel_outcome_known?(outcome: r) }
509
581
  scored, unscored = rows.partition { |r| !r[:score].nil? }
510
582
  ordered = scored.reverse + unscored.reverse
511
583
  added = 0
@@ -1564,12 +1636,23 @@ module PWN
1564
1636
  nil
1565
1637
  end
1566
1638
 
1639
+ private_class_method def self.sentinel_outcome_known?(opts = {})
1640
+ row = opts[:outcome] || {}
1641
+ return false if row[:verdict].to_s == 'unknown'
1642
+ return !row[:training_score].nil? if row.key?(:training_score) || row.key?(:decision_version)
1643
+
1644
+ source = (row[:source] || row[:judge_source]).to_s
1645
+ return false if source.start_with?('heuristic') || source == 'error'
1646
+
1647
+ !row[:judge].nil? || !row[:score].nil? || [true, false, 'true', 'false', 'soft'].include?(row[:success])
1648
+ end
1649
+
1567
1650
  private_class_method def self.record_sentinel(opts = {})
1568
1651
  s = normalize_sentinel(raw: load_sentinel)
1569
1652
  # Clamp judge to [0,1] — LLM/heuristic should already, but a bad
1570
1653
  # write must not poison rolling means forever.
1571
1654
  judge = opts[:judge].to_f.clamp(0.0, 1.0)
1572
- entry = { judge: judge, at: Time.now.utc.iso8601 }
1655
+ entry = opts.slice(:training_score, :decision_version, :verdict).merge(judge: judge, at: Time.now.utc.iso8601)
1573
1656
  # P1 — optional per-sample confidence (heuristic < LLM ORM)
1574
1657
  entry[:confidence] = opts[:confidence].to_f.clamp(0.0, 1.0) unless opts[:confidence].nil?
1575
1658
  entry[:source] = opts[:source].to_s unless opts[:source].to_s.empty?
@@ -1642,10 +1725,15 @@ module PWN
1642
1725
  private_class_method def self.normalize_sentinel(opts = {})
1643
1726
  raw = opts.is_a?(Hash) && opts.key?(:raw) ? opts[:raw] : opts
1644
1727
  s = (raw.is_a?(Hash) ? raw.dup : empty_sentinel)
1645
- s[:window] = Array(s[:window]).map do |e|
1646
- next nil unless e.is_a?(Hash)
1647
-
1648
- h = { judge: e[:judge].to_f.clamp(0.0, 1.0) }
1728
+ window = Array(s[:window])
1729
+ known = window.select { |e| e.is_a?(Hash) && sentinel_outcome_known?(outcome: e) }
1730
+ if known.length < window.length
1731
+ s[:proxy_distrust] = 0.0
1732
+ s.delete(:distrust_at)
1733
+ s[:distrust_meta] = { reason: 'unknown_outcomes_removed', cleared: true }
1734
+ end
1735
+ s[:window] = known.map do |e|
1736
+ h = e.slice(:training_score, :decision_version, :verdict).merge(judge: e[:judge].to_f.clamp(0.0, 1.0))
1649
1737
  h[:at] = e[:at] if e[:at]
1650
1738
  h[:source] = e[:source].to_s unless e[:source].to_s.empty?
1651
1739
  h[:confidence] = e[:confidence].to_f.clamp(0.0, 1.0) unless e[:confidence].nil?
@@ -1833,9 +1921,21 @@ module PWN
1833
1921
  critic_pass: 'optional - critic pass value consumed by #judge',
1834
1922
  predicted: 'optional - predicted value consumed by #judge',
1835
1923
  proxy_ok: 'optional - proxy ok value consumed by #judge',
1836
- persist_components: 'optional - write score_components into the learning ledger',
1837
- verifier_verdict: 'optional - :pass when a deterministic verifier already succeeded',
1838
- verifier_pass: 'optional - true as a boolean alias for verifier_verdict :pass'
1924
+ persist_components: 'optional - persist the resolved outcome and score components'
1925
+ )
1926
+
1927
+ # Trusted host verifier only: actually check ALL original-request criteria.
1928
+ # PASS prose, exit zero, and single confirmed claims are not substitutes.
1929
+ #{self}.record_verification(
1930
+ request: 'required - exact current session user request',
1931
+ session_id: 'required - session holding the request and verification',
1932
+ checks: 'required - complete Array of {criterion:, passed: Boolean, evidence:}'
1933
+ )
1934
+
1935
+ # Shared verdict and training eligibility; unknown training_score is nil.
1936
+ #{self}.resolve_outcome(
1937
+ outcome: 'required - outcome Hash from the judge or trusted evaluator',
1938
+ critic_pass: 'optional - false records a critic disagreement'
1839
1939
  )
1840
1940
 
1841
1941
  # Run promote to success and return its result