pwn 0.5.718 → 0.5.720

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
  3. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
  4. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +1 -0
  5. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
  7. data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
  8. data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
  9. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
  11. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
  13. data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
  14. data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  16. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
  17. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  18. data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
  20. data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
  21. data/lib/pwn/ai/agent/dispatch.rb +67 -0
  22. data/lib/pwn/ai/agent/engagement.rb +167 -0
  23. data/lib/pwn/ai/agent/learning.rb +88 -4
  24. data/lib/pwn/ai/agent/loop.rb +20 -2
  25. data/lib/pwn/ai/agent/reward.rb +76 -12
  26. data/lib/pwn/ai/agent/swarm.rb +36 -0
  27. data/lib/pwn/ai/agent/tool_guard.rb +39 -0
  28. data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
  29. data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
  30. data/lib/pwn/ai/agent/turn_finalizer.rb +65 -0
  31. data/lib/pwn/ai/agent.rb +1 -0
  32. data/lib/pwn/config.rb +18 -2
  33. data/lib/pwn/plugins/bin_workspace.rb +81 -0
  34. data/lib/pwn/plugins/binary_parser.rb +27 -0
  35. data/lib/pwn/plugins/capability.rb +68 -0
  36. data/lib/pwn/plugins/detect_os.rb +1 -2
  37. data/lib/pwn/plugins/detonate.rb +62 -0
  38. data/lib/pwn/plugins/emulator.rb +64 -0
  39. data/lib/pwn/plugins/exploit_db.rb +33 -9
  40. data/lib/pwn/plugins/findings.rb +47 -0
  41. data/lib/pwn/plugins/gdb.rb +42 -0
  42. data/lib/pwn/plugins/packet.rb +26 -0
  43. data/lib/pwn/plugins/repl.rb +25 -2
  44. data/lib/pwn/plugins/vault.rb +116 -0
  45. data/lib/pwn/plugins.rb +4 -0
  46. data/lib/pwn/reports/engagement.rb +63 -0
  47. data/lib/pwn/reports.rb +1 -0
  48. data/lib/pwn/version.rb +1 -1
  49. data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
  50. data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
  51. data/spec/lib/pwn/ai/agent/loop_spec.rb +1 -1
  52. data/spec/lib/pwn/ai/agent/reward_spec.rb +16 -0
  53. data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
  54. data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
  55. data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
  56. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
  57. data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
  58. data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
  59. data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -14
  60. data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
  61. data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
  62. data/spec/lib/pwn/plugins/exploit_db_spec.rb +38 -3
  63. data/spec/lib/pwn/plugins/findings_spec.rb +13 -0
  64. data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
  65. data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
  66. data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
  67. data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
  68. data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
  69. data/third_party/pwn_rdoc.jsonl +63 -0
  70. metadata +25 -1
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'ipaddr'
6
+ require 'uri'
7
+
8
+ module PWN
9
+ module AI
10
+ module Agent
11
+ # First-class engagement scope, RoE, and findings pointer.
12
+ module Engagement
13
+ ROOT = File.join(Dir.home, '.pwn', 'engagements')
14
+ ACTIVE_FILE = File.join(ROOT, 'active')
15
+
16
+ public_class_method def self.open(opts = {})
17
+ name = (opts[:name] || opts[:engagement] || 'default').to_s
18
+ raise 'ERROR: name is required' if name.empty?
19
+
20
+ FileUtils.mkdir_p(ROOT)
21
+ path = File.join(ROOT, "#{name}.json")
22
+ row = File.file?(path) ? JSON.parse(File.read(path), symbolize_names: true) : {}
23
+ row[:name] = name
24
+ row[:scope_cidrs] = Array(opts[:scope_cidrs] || row[:scope_cidrs])
25
+ row[:scope_domains] = Array(opts[:scope_domains] || row[:scope_domains])
26
+ row[:excluded] = Array(opts[:excluded] || row[:excluded])
27
+ row[:roe] = (opts[:roe] || row[:roe]).to_s
28
+ row[:window] = opts[:window] || row[:window]
29
+ row[:findings] ||= []
30
+ File.write(path, JSON.pretty_generate(row))
31
+ File.write(ACTIVE_FILE, name)
32
+ row.merge(path: path, active: true)
33
+ end
34
+
35
+ public_class_method def self.close(opts = {})
36
+ FileUtils.rm_f(ACTIVE_FILE) if opts.is_a?(Hash)
37
+ { active: nil }
38
+ end
39
+
40
+ public_class_method def self.status(opts = {})
41
+ name = (opts[:name] || current_name).to_s
42
+ return { active: nil } if name.empty?
43
+
44
+ path = File.join(ROOT, "#{name}.json")
45
+ return { active: name, missing: true } unless File.file?(path)
46
+
47
+ JSON.parse(File.read(path), symbolize_names: true).merge(active: name)
48
+ end
49
+
50
+ public_class_method def self.current_name(opts = {})
51
+ return nil unless opts.is_a?(Hash)
52
+ return nil unless File.file?(ACTIVE_FILE)
53
+
54
+ File.read(ACTIVE_FILE).strip
55
+ end
56
+
57
+ public_class_method def self.in_scope?(opts = {})
58
+ token = (opts[:host] || opts[:ip] || opts[:target] || opts[:url]).to_s
59
+ return true if token.empty?
60
+
61
+ row = status
62
+ return true if row[:active].nil? || row[:missing]
63
+
64
+ enforce = engagement_enforce
65
+ return true if enforce == 'off'
66
+
67
+ host = token
68
+ begin
69
+ host = URI.parse(token).host || token if token.include?('://')
70
+ rescue StandardError
71
+ host = token
72
+ end
73
+ host = host.sub(%r{\Ahttps?://}i, '').split('/').first.to_s.split(':').first
74
+ return false if Array(row[:excluded]).any? { |ex| host.include?(ex.to_s) }
75
+
76
+ cidrs = Array(row[:scope_cidrs]).map(&:to_s).reject(&:empty?)
77
+ domains = Array(row[:scope_domains]).map(&:to_s).reject(&:empty?)
78
+ return true if cidrs.empty? && domains.empty?
79
+
80
+ ip_ok = begin
81
+ addr = IPAddr.new(host)
82
+ cidrs.any? { |c| IPAddr.new(c).include?(addr) }
83
+ rescue StandardError
84
+ false
85
+ end
86
+ dom_ok = domains.any? { |d| host == d || host.end_with?(".#{d}") }
87
+ ip_ok || dom_ok
88
+ end
89
+
90
+ public_class_method def self.deny_if_out_of_scope(opts = {})
91
+ args = opts[:args] || opts[:command] || opts[:text]
92
+ blob = args.is_a?(Hash) ? args.inspect : args.to_s
93
+ tokens = blob.scan(/(?:\d{1,3}\.){3}\d{1,3}|[A-Za-z0-9.-]+\.[A-Za-z]{2,}/).uniq
94
+ bad = tokens.reject { |tok| in_scope?(host: tok) }
95
+ return nil if bad.empty?
96
+
97
+ {
98
+ success: false,
99
+ error: "out_of_scope: #{bad.first}",
100
+ code: 'SCOPE_DENY',
101
+ violating: bad
102
+ }
103
+ end
104
+
105
+ public_class_method def self.authors
106
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
107
+ end
108
+
109
+ public_class_method def self.help
110
+ puts "USAGE:
111
+ # Open or update an engagement and mark it active.
112
+ #{self}.open(
113
+ name: 'required - engagement name',
114
+ engagement: 'optional - alias for name',
115
+ scope_cidrs: 'optional - Array of CIDR strings',
116
+ scope_domains: 'optional - Array of DNS suffixes',
117
+ excluded: 'optional - Array of excluded hosts',
118
+ roe: 'optional - rules of engagement text',
119
+ window: 'optional - time window string'
120
+ )
121
+
122
+ # Clear the active engagement pointer.
123
+ #{self}.close(
124
+ name: 'optional - unused; closing always clears the active pointer'
125
+ )
126
+
127
+ # Return the active engagement document.
128
+ #{self}.status(
129
+ name: 'optional - engagement name (defaults to active)'
130
+ )
131
+
132
+ # Return the active engagement name or nil.
133
+ #{self}.current_name(
134
+ unused: 'optional - reserved'
135
+ )
136
+
137
+ # True when host/ip/url is inside the active engagement scope.
138
+ #{self}.in_scope?(
139
+ host: 'optional - DNS hostname to test against scope_domains',
140
+ ip: 'optional - IPv4 or IPv6 address to test against scope_cidrs',
141
+ target: 'optional - host or IP alias when the caller has one field',
142
+ url: 'optional - URL whose host is extracted and tested'
143
+ )
144
+
145
+ # Structured denial when args mention an out-of-scope host.
146
+ #{self}.deny_if_out_of_scope(
147
+ args: 'optional - Hash of tool args',
148
+ command: 'optional - command string',
149
+ text: 'optional - free-form blob to scan'
150
+ )
151
+
152
+ # Print the AUTHOR(S) string for this module.
153
+ #{self}.authors
154
+ "
155
+ constants.sort
156
+ end
157
+
158
+ private_class_method def self.engagement_enforce
159
+ v = (PWN::Env.dig(:ai, :engagement, :enforce) if defined?(PWN::Env))
160
+ (v || 'block').to_s
161
+ rescue StandardError
162
+ 'block'
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -128,6 +128,16 @@ module PWN
128
128
  entry[:score] = opts[:score].to_f if opts.key?(:score)
129
129
  src = opts[:judge_source].to_s
130
130
  entry[:judge_source] = src unless src.empty?
131
+ vv = (opts[:verifier_verdict] || opts['verifier_verdict']).to_s
132
+ entry[:verifier_verdict] = vv unless vv.empty?
133
+ vc = (opts[:verdict_class] || opts['verdict_class']).to_s
134
+ entry[:verdict_class] = vc unless vc.empty?
135
+ 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
140
+ end
131
141
  FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
132
142
  File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
133
143
  maybe_prune_outcomes!
@@ -137,7 +147,7 @@ module PWN
137
147
  # are promoted into PWN::Memory[:lesson] so PromptBuilder recall
138
148
  # survives across sessions. Without this, the agent re-learns
139
149
  # "run rubocop after every patch" every turn (empty memory.json).
140
- promote_process_lesson(entry: entry) if defined?(PWN::Memory)
150
+ promote_process_lesson(entry: entry) if defined?(PWN::Memory) && entry[:status].to_s != 'conflicted'
141
151
  if opts.key?(:score) && defined?(Curriculum) && Curriculum.respond_to?(:calibrate)
142
152
  pred = opts[:predicted]
143
153
  pred = Thread.current[:pwn_plan_predicted] if pred.nil?
@@ -215,6 +225,8 @@ module PWN
215
225
  # envelope rows (REQUEST:/GOAL: prefixes) without starving the block.
216
226
  rows = prefer_primary_tasks(rows: outcomes(limit: limit * 4)).first(limit)
217
227
  fails = prefer_primary_tasks(rows: outcomes(limit: 200, success: false))
228
+ fails = fails.reject { |r| r[:status].to_s == 'conflicted' }
229
+ fails = fails.reject { |r| r[:verifier_verdict].to_s == 'pass' }
218
230
  # Do not mirror the same ids under both headings — that doubled the
219
231
  # failure signal and made RECENT OUTCOMES == RECENT FAILURES when the
220
232
  # last N attempts all failed (the injected block looked "stuck").
@@ -234,8 +246,12 @@ module PWN
234
246
  # Surface a one-line cause crumb so the agent can actually learn
235
247
  # from failures instead of only seeing that they failed.
236
248
  if r[:success] != true
237
- crumb = cause_crumb(details: r[:details])
238
- line += "\n cause: #{crumb}" unless crumb.empty?
249
+ if r[:verdict_class].to_s == ''
250
+ crumb = cause_crumb(details: r[:details])
251
+ line += "\n cause: #{crumb}" unless crumb.empty?
252
+ else
253
+ line += "\n cause: #{r[:verdict_class]} #{r[:remediation_hint]}"
254
+ end
239
255
  end
240
256
  line
241
257
  end
@@ -1049,6 +1065,7 @@ module PWN
1049
1065
 
1050
1066
  private_class_method def self.cause_crumb(opts = {})
1051
1067
  d = opts[:details].to_s.gsub(/\s+/, ' ').strip
1068
+ d = d.gsub(/overlap=\S+/, '').gsub(/ratio=\S+/, '').strip
1052
1069
  return '' if d.empty?
1053
1070
 
1054
1071
  # Prefer explicit FLAW / CORRECTED crumbs; else verdict(score) head.
@@ -1818,6 +1835,55 @@ module PWN
1818
1835
  File.write(LESSONS_FILE, JSON.pretty_generate(opts[:store]))
1819
1836
  end
1820
1837
 
1838
+ private_class_method def self.learning_max_baks(opts = {})
1839
+ n = opts[:max_baks]
1840
+ return n.to_i if n.to_i.positive?
1841
+ return 5 unless defined?(PWN::Env)
1842
+
1843
+ v = PWN::Env.dig(:ai, :learning, :max_baks)
1844
+ v.to_i.positive? ? v.to_i : 5
1845
+ rescue StandardError
1846
+ 5
1847
+ end
1848
+
1849
+ public_class_method def self.list_conflicted(opts = {})
1850
+ limit = (opts[:limit] || 50).to_i
1851
+ outcomes(limit: 500).select { |r| r[:status].to_s == 'conflicted' }.first(limit)
1852
+ end
1853
+
1854
+ public_class_method def self.requeue_conflicted(opts = {})
1855
+ dry = opts[:dry_run] ? true : false
1856
+ rows = list_conflicted(limit: 10_000)
1857
+ return { rescored: 0, dry_run: dry } if rows.empty? || dry
1858
+
1859
+ n = 0
1860
+ rows.each do |r|
1861
+ note_outcome(
1862
+ task: "requeue:#{r[:task]}",
1863
+ success: true,
1864
+ score: [r[:score].to_f, 0.7].max,
1865
+ verifier_verdict: :pass,
1866
+ details: 'rescored after verifier precedence',
1867
+ tags: %w[requeue]
1868
+ )
1869
+ n += 1
1870
+ end
1871
+ { rescored: n, dry_run: false }
1872
+ end
1873
+
1874
+ public_class_method def self.compact!(opts = {})
1875
+ max_baks = (opts[:max_baks] || learning_max_baks).to_i
1876
+ max_baks = 5 if max_baks <= 0
1877
+ dir = File.dirname(LEARNING_FILE)
1878
+ baks = Dir[File.join(dir, '*.bak*')].sort_by { |p| File.mtime(p) }.reverse
1879
+ pruned = 0
1880
+ baks.drop(max_baks).each do |path|
1881
+ File.delete(path)
1882
+ pruned += 1
1883
+ end
1884
+ { pruned: pruned, kept: [baks.length, max_baks].min, max_baks: max_baks }
1885
+ end
1886
+
1821
1887
  # Author(s):: 0day Inc. <support@0dayinc.com>
1822
1888
 
1823
1889
  public_class_method def self.authors
@@ -1839,7 +1905,25 @@ module PWN
1839
1905
  judge_source: 'required - judge source value consumed by #note_outcome',
1840
1906
  predicted: 'optional - predicted value consumed by #note_outcome',
1841
1907
  confidence: 'optional - confidence value consumed by #note_outcome',
1842
- engine: 'optional - engine value consumed by #note_outcome'
1908
+ engine: 'optional - engine value consumed by #note_outcome',
1909
+ verifier_verdict: 'optional - pass|fail from a deterministic verifier',
1910
+ verdict_class: 'optional - missing_artifact|wrong_path|unverified_claim|scope_miss|partial_coverage|style_only',
1911
+ remediation_hint: 'optional - one-line fix hint'
1912
+ )
1913
+
1914
+ # List outcomes tagged conflicted (verifier PASS vs low judge).
1915
+ #{self}.list_conflicted(
1916
+ limit: 'optional - max entries (defaults to 50)'
1917
+ )
1918
+
1919
+ # Rescore conflicted outcomes after verifier-precedence lands.
1920
+ #{self}.requeue_conflicted(
1921
+ dry_run: 'optional - true to count without writing'
1922
+ )
1923
+
1924
+ # Prune excess *.bak siblings under the learning directory.
1925
+ #{self}.compact!(
1926
+ max_baks: 'optional - newest bak files to keep (defaults to 5)'
1843
1927
  )
1844
1928
 
1845
1929
  # Run outcomes and return its result
@@ -661,7 +661,6 @@ module PWN
661
661
 
662
662
  private_class_method def self.declared_contract_unsatisfied?(opts = {})
663
663
  return false if verify_passed?(messages: opts[:messages])
664
- return false if write_or_read_evidenced?(messages: opts[:messages])
665
664
 
666
665
  completion_unmet(opts).any?
667
666
  rescue StandardError
@@ -670,7 +669,6 @@ module PWN
670
669
 
671
670
  private_class_method def self.completion_unmet(opts = {})
672
671
  return [] if verify_passed?(messages: opts[:messages])
673
- return [] if write_or_read_evidenced?(messages: opts[:messages])
674
672
 
675
673
  contract = declared_contract(request: opts[:request])
676
674
  request = opts[:request].to_s
@@ -3145,6 +3143,20 @@ module PWN
3145
3143
  TurnFinalizer.leave_user_path! if defined?(TurnFinalizer)
3146
3144
  end
3147
3145
 
3146
+ public_class_method def self.budget_status(opts = {})
3147
+ t0 = opts[:t0] || Thread.current[:pwn_loop_t0] || Time.now
3148
+ elapsed = Time.now - t0
3149
+ remain = (opts[:remaining_s] || Thread.current[:pwn_loop_budget_s] || 10_800).to_f - elapsed
3150
+ {
3151
+ elapsed_s: elapsed.round,
3152
+ remaining_tool_budget_s: [remain, 0].max.round,
3153
+ mutations_used: Thread.current[:pwn_loop_mutations].to_i,
3154
+ mutations_max: 10,
3155
+ context_tokens_used: Thread.current[:pwn_loop_tokens].to_i,
3156
+ est_max: 128_000
3157
+ }
3158
+ end
3159
+
3148
3160
  # Author(s):: 0day Inc. <support@0dayinc.com>
3149
3161
 
3150
3162
  public_class_method def self.authors
@@ -3203,6 +3215,12 @@ module PWN
3203
3215
  force_tools: 'optional - force tools value consumed by #run'
3204
3216
  )
3205
3217
 
3218
+ # Remaining time/token/mutation budget for the current loop.
3219
+ #{self}.budget_status(
3220
+ t0: 'optional - session start Time (defaults to thread t0)',
3221
+ remaining_s: 'optional - override remaining tool budget seconds'
3222
+ )
3223
+
3206
3224
  # Print the AUTHOR(S) string for this module.
3207
3225
  #{self}.authors
3208
3226
  "
@@ -177,22 +177,36 @@ module PWN
177
177
  v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
178
178
  end
179
179
 
180
- v[:success] = promote_to_success?(
181
- orm: v[:source].to_s != 'heuristic' && v[:score].to_f >= 0.6,
182
- verify: if ground.nil?
183
- nil
184
- else
185
- ground[:verdict] == :confirmed
186
- end,
187
- critic: opts.key?(:critic_pass) ? opts[:critic_pass] : nil
188
- )
180
+ pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
181
+ 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))
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
189
202
  v[:needs_spot_check] = v[:success] && v[:score].to_f >= 0.85 && (rand < 0.05)
190
203
  v[:engine] = eng
191
- pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
192
204
  v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
193
205
  if pass
194
206
  v[:score] = [v[:score].to_f, 0.7].max
195
207
  v[:score] = [v[:score].to_f, 0.70].min
208
+ v[:success] = true if prec
209
+ v[:verifier_verdict] ||= :pass
196
210
  end
197
211
  v[:score_components] ||= {
198
212
  judge: v[:score].to_f,
@@ -201,7 +215,16 @@ module PWN
201
215
  weights: { overlap: pass ? 0.0 : 0.15 }
202
216
  }
203
217
  v[:score_components][:weights][:overlap] = 0.0 if pass
204
- Learning.note_outcome(task: request[0, 80], success: v[:score].to_f >= 0.6, score: v[:score], details: v[:score_components].to_json) if commit && defined?(Learning) && opts[:persist_components]
218
+ if commit && defined?(Learning) && opts[:persist_components]
219
+ Learning.note_outcome(
220
+ task: request[0, 80],
221
+ success: v[:success],
222
+ score: v[:score],
223
+ details: v[:score_components].to_json,
224
+ verifier_verdict: v[:verifier_verdict],
225
+ verdict_class: v[:verdict_class]
226
+ )
227
+ end
205
228
  # W3 — write Brier on every judged turn so overconfidence can
206
229
  # throttle max_iters/critic even when plan_first never fired.
207
230
  if commit
@@ -1254,6 +1277,45 @@ module PWN
1254
1277
  false
1255
1278
  end
1256
1279
 
1280
+ private_class_method def self.verifier_precedence?(opts = {})
1281
+ return true unless opts.is_a?(Hash)
1282
+ return true unless defined?(PWN::Env)
1283
+
1284
+ v = PWN::Env.dig(:ai, :reward, :verifier_precedence)
1285
+ v != false
1286
+ rescue StandardError
1287
+ true
1288
+ end
1289
+
1290
+ private_class_method def self.taxonomy_class(opts = {})
1291
+ given = (opts[:verdict_class] || opts['verdict_class']).to_s
1292
+ return given unless given.empty?
1293
+
1294
+ vv = opts[:verifier_verdict]
1295
+ score = opts[:score].to_f
1296
+ final = opts[:final].to_s
1297
+ request = opts[:request].to_s
1298
+ return 'unverified_claim' if vv == :pass && score < 0.6
1299
+ return 'missing_artifact' if request.match?(%r{/(?:tmp|home|opt|var)/}) && !final.match?(%r{/(?:tmp|home|opt|var)/})
1300
+ return 'wrong_path' if request.include?('/tmp/') && final.include?('/tmp/') && request.split.none? { |tok| tok.start_with?('/') && final.include?(tok) }
1301
+ return 'scope_miss' if final.match?(/out of scope|SCOPE_DENY/i)
1302
+ return 'partial_coverage' if score.between?(0.3, 0.59)
1303
+ return 'style_only' if score >= 0.6 && Array(opts[:trace]).empty?
1304
+
1305
+ 'unverified_claim'
1306
+ end
1307
+
1308
+ private_class_method def self.taxonomy_hint(opts = {})
1309
+ {
1310
+ 'missing_artifact' => 'Write the requested path then read it back.',
1311
+ 'wrong_path' => 'Use the absolute path named in the original request.',
1312
+ 'unverified_claim' => 'Add a verifier PASS (file readback, exit 0, or hash match).',
1313
+ 'scope_miss' => 'Stay inside the active engagement scope.',
1314
+ 'partial_coverage' => 'Finish remaining request clauses before claiming done.',
1315
+ 'style_only' => 'Produce a host-visible artifact, not prose restyling.'
1316
+ }[opts[:verdict_class].to_s] || 'Produce evidence that matches the original request.'
1317
+ end
1318
+
1257
1319
  private_class_method def self.cheap_orm_timeout
1258
1320
  n = agent_flag(key: :reward_llm_timeout, default: CHEAP_ORM_TIMEOUT).to_i
1259
1321
  n = CHEAP_ORM_TIMEOUT if n < 2
@@ -1770,7 +1832,9 @@ module PWN
1770
1832
  critic_pass: 'optional - critic pass value consumed by #judge',
1771
1833
  predicted: 'optional - predicted value consumed by #judge',
1772
1834
  proxy_ok: 'optional - proxy ok value consumed by #judge',
1773
- persist_components: 'optional - write score_components into the learning ledger'
1835
+ persist_components: 'optional - write score_components into the learning ledger',
1836
+ verifier_verdict: 'optional - :pass when a deterministic verifier already succeeded',
1837
+ verifier_pass: 'optional - true as a boolean alias for verifier_verdict :pass'
1774
1838
  )
1775
1839
 
1776
1840
  # Run promote to success and return its result
@@ -438,6 +438,34 @@ module PWN
438
438
  "FACTS #{rows.map { |r| "#{r[:kind]}=#{r[:value]}" }.join(' ')}"
439
439
  end
440
440
 
441
+ public_class_method def self.claim(opts = {})
442
+ eng = (opts[:engagement_id] || 'default').to_s
443
+ unit = opts[:unit].to_s
444
+ raise 'ERROR: unit is required' if unit.empty?
445
+
446
+ ttl = (opts[:ttl] || 300).to_i
447
+ agent = (opts[:agent_id] || 'anon').to_s
448
+ dir = File.join(Dir.home, '.pwn', 'swarm', eng)
449
+ FileUtils.mkdir_p(dir)
450
+ path = File.join(dir, "#{unit.gsub(/[^A-Za-z0-9._:-]/, '_')}.claim")
451
+ now = Time.now.to_i
452
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
453
+ f.flock(File::LOCK_EX)
454
+ existing = begin
455
+ JSON.parse(f.read, symbolize_names: true)
456
+ rescue StandardError
457
+ {}
458
+ end
459
+ return { ok: false, unit: unit, holder: existing[:agent_id] } if existing[:until].to_i > now && existing[:agent_id].to_s != agent
460
+
461
+ row = { unit: unit, agent_id: agent, until: now + ttl }
462
+ f.rewind
463
+ f.truncate(0)
464
+ f.write(JSON.generate(row))
465
+ { ok: true, unit: unit, agent_id: agent, until: row[:until] }
466
+ end
467
+ end
468
+
441
469
  # Author(s):: 0day Inc. <support@0dayinc.com>
442
470
 
443
471
  public_class_method def self.authors
@@ -535,6 +563,14 @@ module PWN
535
563
  engagement_id: 'optional - engagement id (defaults to default)'
536
564
  )
537
565
 
566
+ # Atomically claim a work unit (INSERT-or-fail until TTL).
567
+ #{self}.claim(
568
+ unit: 'required - normalized target+phase key',
569
+ engagement_id: 'optional - engagement id (defaults to default)',
570
+ ttl: 'optional - seconds until the claim expires (defaults to 300)',
571
+ agent_id: 'optional - claimant id'
572
+ )
573
+
538
574
  # Print the AUTHOR(S) string for this module.
539
575
  #{self}.authors
540
576
  "
@@ -442,6 +442,39 @@ module PWN
442
442
  nil
443
443
  end
444
444
 
445
+ public_class_method def self.policy_decision(opts = {})
446
+ name = opts[:name].to_s
447
+ blob = opts[:args].inspect
448
+ path = File.join(Dir.home, '.pwn', 'toolguard.yaml')
449
+ return nil unless File.file?(path)
450
+
451
+ require 'yaml'
452
+ doc = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
453
+ Array(doc['rules'] || doc[:rules]).each do |rule|
454
+ next unless rule.is_a?(Hash)
455
+
456
+ tool = (rule['tool'] || rule[:tool]).to_s
457
+ next unless tool.empty? || tool == name
458
+
459
+ pat = (rule['arg_pattern'] || rule[:arg_pattern]).to_s
460
+ next if pat.empty? || blob.match?(Regexp.new(pat)) == false
461
+
462
+ action = (rule['action'] || rule[:action] || 'allow').to_s
463
+ next if action == 'allow'
464
+
465
+ return {
466
+ action: action,
467
+ success: false,
468
+ error: (rule['reason'] || rule[:reason] || 'toolguard policy deny').to_s,
469
+ code: 'POLICY_DENY',
470
+ tool: name
471
+ }
472
+ end
473
+ nil
474
+ rescue StandardError
475
+ nil
476
+ end
477
+
445
478
  public_class_method def self.rfc1918?(opts = {})
446
479
  oct = opts[:ip].to_s.split('.').map(&:to_i)
447
480
  return false unless oct.length == 4
@@ -690,6 +723,12 @@ module PWN
690
723
  payload: 'optional - command string if class omitted'
691
724
  )
692
725
 
726
+ # Evaluate ~/.pwn/toolguard.yaml first-match rules.
727
+ #{self}.policy_decision(
728
+ name: 'required - tool name',
729
+ args: 'required - Hash of tool arguments'
730
+ )
731
+
693
732
  # Print the AUTHOR(S) string for this module.
694
733
  #{self}.authors
695
734
  "