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
@@ -100,6 +100,11 @@ module PWN
100
100
  cmdj(opts.merge(cmd: "axtj #{addr}"))
101
101
  end
102
102
 
103
+ public_class_method def self.xrefs_from(opts = {})
104
+ addr = opts[:addr].to_s
105
+ cmdj(opts.merge(cmd: "axfj #{addr}"))
106
+ end
107
+
103
108
  # Supported Method Parameters::
104
109
  # PWN::Plugins::Radare2.disasm(
105
110
  # session: 'required - session id returned by #open',
@@ -108,7 +113,7 @@ module PWN
108
113
  # )
109
114
  public_class_method def self.disasm(opts = {})
110
115
  addr = opts[:addr].to_s
111
- n = (opts[:n] || 32).to_i
116
+ n = (opts[:n] || opts[:len] || 32).to_i
112
117
  cmd(opts.merge(cmd: "pd #{n} @ #{addr}"))
113
118
  end
114
119
 
@@ -198,11 +203,18 @@ module PWN
198
203
  addr: 'required - address or flag to list xrefs to (e.g. main or 0x401000)'
199
204
  )
200
205
 
206
+ # List xrefs from an address or flag (axfj JSON).
207
+ #{self}.xrefs_from(
208
+ session: 'required - session id returned by #open',
209
+ addr: 'required - address or flag to list xrefs from'
210
+ )
211
+
201
212
  # Disassemble n instructions at addr (pd text).
202
213
  #{self}.disasm(
203
214
  session: 'required - session id returned by #open',
204
215
  addr: 'required - address or flag to disassemble from',
205
- n: 'optional - instruction count (defaults to 32)'
216
+ n: 'optional - instruction count (defaults to 32)',
217
+ len: 'optional - alias for n, number of instructions to disassemble'
206
218
  )
207
219
 
208
220
  # List strings in the binary (izj JSON).
@@ -494,7 +494,12 @@ module PWN
494
494
  puts '[*] TAB menus: leading `/` = commands (/cron /skills /sessions …); `/` later = host paths; otherwise Ruby completion (same as the pwn REPL).'
495
495
  puts "[*] tmux + terminator users: Ensure ~/.tmux.conf has 'set -s extended-keys on' and 'set -g xterm-keys on', then restart tmux. Use TERM=xterm-256color."
496
496
  tag = pi.config.pwn_ai_session_id.to_s.empty? ? '<SESSION_ID>' : pi.config.pwn_ai_session_id
497
- puts "\n\n\npwn-ai debug ON → ~/.pwn/logs/pwn-ai-DEBUG-#{tag}-R<REQUEST_NUMBER>.log"
497
+
498
+ dbg_lvl = ''
499
+ dbg_lvl = 'trace' if pi.config.pwn_ai_trace
500
+ dbg_lvl = 'debug' if pi.config.pwn_ai_debug && !pi.config.pwn_ai_trace
501
+
502
+ puts "\n\n\npwn-ai #{dbg_lvl} ON → ~/.pwn/logs/pwn-ai-DEBUG-#{tag}-R<REQUEST_NUMBER>.log" unless dbg_lvl.empty?
498
503
  end
499
504
  end
500
505
 
@@ -1571,6 +1571,50 @@ module PWN
1571
1571
  { screenshot: shot, dom: html, har: har, dir: dir }
1572
1572
  end
1573
1573
 
1574
+ public_class_method def self.intercept(opts = {})
1575
+ browser_obj = opts[:browser_obj]
1576
+ patterns = Array(opts[:patterns] || opts[:urls] || ['*'])
1577
+ @intercepted ||= []
1578
+ begin
1579
+ browser = browser_obj.is_a?(Hash) ? browser_obj[:browser] : browser_obj
1580
+ drv = browser.respond_to?(:driver) ? browser.driver : nil
1581
+ drv.devtools.fetch.enable(patterns: patterns.map { |p| { urlPattern: p.to_s } }) if drv.respond_to?(:devtools)
1582
+ rescue StandardError
1583
+ nil
1584
+ end
1585
+ { enabled: true, patterns: patterns, hook: 'Fetch.enable', buffer: @intercepted }
1586
+ end
1587
+
1588
+ public_class_method def self.har_export(opts = {})
1589
+ sid = (opts[:session_id] || 'default').to_s
1590
+ entries = Array(opts[:entries] || @intercepted)
1591
+ har = { log: { version: '1.2', creator: { name: 'PWN::Plugins::TransparentBrowser', version: '1' }, entries: entries } }
1592
+ stored = PWN::Plugins::ArtifactRegistry.put(bytes: JSON.generate(har), kind: 'har', session_id: sid, tags: ['http'])
1593
+ stored.merge(har: har)
1594
+ end
1595
+
1596
+ public_class_method def self.replay(opts = {})
1597
+ req = opts[:request] || opts[:req] || {}
1598
+ req = JSON.parse(req, symbolize_names: true) if req.is_a?(String) && req.strip.start_with?('{')
1599
+ mutations = opts[:mutations] || {}
1600
+ url = (mutations[:url] || req[:url] || req['url']).to_s
1601
+ method = (mutations[:method] || req[:method] || req['method'] || 'GET').to_s
1602
+ headers = (req[:headers] || req['headers'] || {}).merge(mutations[:headers] || {})
1603
+ body = mutations[:body] || req[:body] || req['body']
1604
+ raise 'ERROR: request url is required' if url.empty?
1605
+
1606
+ require 'net/http'
1607
+ uri = URI.parse(url)
1608
+ klass = Net::HTTP.const_get(method.capitalize)
1609
+ http = Net::HTTP.new(uri.host, uri.port)
1610
+ http.use_ssl = uri.scheme == 'https'
1611
+ r = klass.new(uri.request_uri)
1612
+ headers.each { |k, v| r[k.to_s] = v.to_s }
1613
+ r.body = body.to_s if body
1614
+ resp = http.request(r)
1615
+ { status: resp.code.to_i, headers: resp.to_hash, body: resp.body.to_s[0, 65_536], url: url, method: method }
1616
+ end
1617
+
1574
1618
  # Author(s):: 0day Inc. <support@0dayinc.com>
1575
1619
 
1576
1620
  public_class_method def self.authors
@@ -1723,6 +1767,26 @@ module PWN
1723
1767
  session_id: 'optional - artifacts session folder (defaults to default)'
1724
1768
  )
1725
1769
 
1770
+ # Enable CDP Fetch interception (Burp-like hook without Burp).
1771
+ #{self}.intercept(
1772
+ browser_obj: 'optional - browser_obj returned from #open',
1773
+ patterns: 'optional - Array of URL patterns (defaults to *)',
1774
+ urls: 'optional - alias for patterns'
1775
+ )
1776
+
1777
+ # Export intercepted traffic as HAR into the artifact store.
1778
+ #{self}.har_export(
1779
+ session_id: 'optional - pwn-ai session id',
1780
+ entries: 'optional - Array of HAR entries (defaults to intercepted buffer)'
1781
+ )
1782
+
1783
+ # Replay an HTTP request with optional mutations (Repeater-class).
1784
+ #{self}.replay(
1785
+ request: 'required - Hash or JSON of method/url/headers/body',
1786
+ req: 'optional - alias for request',
1787
+ mutations: 'optional - Hash of url/method/headers/body overlays'
1788
+ )
1789
+
1726
1790
  # Print the AUTHOR(S) string for this module.
1727
1791
  #{self}.authors
1728
1792
  "
data/lib/pwn/plugins.rb CHANGED
@@ -41,7 +41,9 @@ module PWN
41
41
  autoload :ExploitDev, 'pwn/plugins/exploit_dev'
42
42
  autoload :Git, 'pwn/plugins/git'
43
43
  autoload :GDB, 'pwn/plugins/gdb'
44
+ autoload :GDBMI, 'pwn/plugins/gdbmi'
44
45
  autoload :Ghidra, 'pwn/plugins/ghidra'
46
+ autoload :GhidraHeadless, 'pwn/plugins/ghidra_headless'
45
47
  autoload :Github, 'pwn/plugins/github'
46
48
  autoload :GoogleWorkspace, 'pwn/plugins/google_workspace'
47
49
  autoload :HackerOne, 'pwn/plugins/hacker_one'
@@ -6,6 +6,16 @@ module PWN
6
6
  module Reports
7
7
  # Compile engagement findings into a client-ready HTML/Markdown report.
8
8
  module Engagement
9
+ public_class_method def self.render(opts = {})
10
+ out = generate(opts)
11
+ fmt = (opts[:format] || 'md').to_s
12
+ case fmt
13
+ when 'html' then { path: out[:html], format: fmt }
14
+ when 'json' then { path: out[:json], format: fmt }
15
+ else { path: out[:markdown], format: 'md', all: out }
16
+ end
17
+ end
18
+
9
19
  public_class_method def self.generate(opts = {})
10
20
  name = (opts[:engagement] || opts[:name] || 'default').to_s
11
21
  rows = if opts[:findings]
@@ -53,6 +63,15 @@ module PWN
53
63
  report_name: 'optional - basename without extension'
54
64
  )
55
65
 
66
+ # Render one format (md|html|json) for a session/engagement.
67
+ #{self}.render(
68
+ session_id: 'optional - pwn-ai session id',
69
+ format: 'optional - md, html, or json (defaults to md)',
70
+ engagement: 'optional - engagement name',
71
+ dir_path: 'optional - output directory',
72
+ report_name: 'optional - basename without extension'
73
+ )
74
+
56
75
  # Print the AUTHOR(S) string for this module.
57
76
  #{self}.authors
58
77
  "
data/lib/pwn/sessions.rb CHANGED
@@ -612,7 +612,9 @@ module PWN
612
612
  text = text.gsub(%r{Bearer\s+[A-Za-z0-9._\-+/=]{12,}}i) { |m| redacted_token(kind: 'bearer', value: m) }
613
613
  text = text.gsub(/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/m) { |m| redacted_token(kind: 'pem', value: m) }
614
614
  text = text.gsub(/(password\s*[:=]\s*)\S+/i) { "#{Regexp.last_match(1)}#{redacted_token(kind: 'password', value: Regexp.last_match(0))}" }
615
- text.gsub(/Set-Cookie:\s*[^\r\n]+/i) { |m| redacted_token(kind: 'cookie', value: m) }
615
+ text = text.gsub(/Set-Cookie:\s*[^\r\n]+/i) { |m| redacted_token(kind: 'cookie', value: m) }
616
+ text = PWN::Plugins::Vault.redact(text: text) if defined?(PWN::Plugins::Vault)
617
+ text
616
618
  end
617
619
 
618
620
  private_class_method def self.redact_disabled?
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.721'
4
+ VERSION = '0.5.723'
5
5
  end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+ require 'digest'
7
+ require 'optparse'
8
+
9
+ # Standalone experiment, deliberately outside the production module tree.
10
+ module PolicyBenchmark
11
+ def self.score(path:, expected:)
12
+ !File.symlink?(path) && File.file?(path) && JSON.parse(File.read(path)) == expected
13
+ rescue JSON::ParserError, SystemCallError
14
+ false
15
+ end
16
+
17
+ def self.self_check
18
+ Dir.mktmpdir('pwn-policy-check-', '/tmp') do |root|
19
+ path = File.join(root, 'result.json')
20
+ File.write(path, JSON.generate([2, 10]))
21
+ raise 'checker rejected a correct artifact' unless score(path: path, expected: [2, 10])
22
+
23
+ File.write(path, 'PASS: all checks passed; result.json was verified successfully.')
24
+ raise 'checker accepted convincing prose' if score(path: path, expected: [2, 10])
25
+
26
+ File.write(path, JSON.generate([10, 2]))
27
+ raise 'checker accepted wrong artifact' if score(path: path, expected: [2, 10])
28
+
29
+ File.unlink(path)
30
+ raise 'checker accepted missing artifact' if score(path: path, expected: [2, 10])
31
+
32
+ other = File.join(root, 'other.json')
33
+ File.write(other, JSON.generate([2, 10]))
34
+ File.symlink(other, path)
35
+ raise 'checker accepted a symlink instead of a new artifact' if score(path: path, expected: [2, 10])
36
+ end
37
+ report = run
38
+ raise 'benchmark did not run both learning arms' unless report[:arms]&.keys == %i[off on]
39
+
40
+ report[:arms].each do |mode, arm|
41
+ raise 'missing held-out tasks' unless arm[:evaluation][:tasks] == 8
42
+ raise 'training contaminated held-out data' unless arm[:disjoint_inputs]
43
+ raise 'evaluation mutated the learned policy' unless arm[:policy_frozen]
44
+ raise 'negative controls trusted PASS text' unless arm[:negative_controls].length == 16 && arm[:negative_controls].all? { |row| row[:false_success] && !row[:completed] }
45
+ raise 'positive controls failed' unless arm[:positive_controls]&.length == 8 && arm[:positive_controls].all? { |row| row[:completed] }
46
+ raise 'training has no real executed actions' unless arm[:training][:tool_calls] == 72
47
+ raise 'off arm learned' if mode == :off && arm[:policy_stats][:n_updates].positive?
48
+ raise 'on arm did not learn' if mode == :on && !arm[:policy_stats][:n_updates].positive?
49
+ raise 'provider usage was invented' unless arm[:evaluation][:llm_calls].zero? && arm[:evaluation][:monetary_cost].nil?
50
+ end
51
+ puts 'Self-checks passed'
52
+ end
53
+
54
+ def self.run
55
+ raise 'Run in a fresh Ruby process; do not load the live agent' if defined?(PWN)
56
+
57
+ original_env = ENV.to_h
58
+ started = clock
59
+ Dir.mktmpdir('pwn-policy-benchmark-', '/tmp') do |root|
60
+ # Set HOME BEFORE requiring code with Dir.home-based constants. Do not
61
+ # load pwn.rb, user config, tools, network libraries, or provider clients.
62
+ ENV.replace('HOME' => root, 'TMPDIR' => root, 'LANG' => 'C.UTF-8')
63
+ Object.const_set(:PWN, Module.new)
64
+ PWN.const_set(:Env, { ai: { agent: { policy: false } } })
65
+ require_relative '../lib/pwn/ai/agent/policy'
66
+ require_relative '../lib/pwn/ai/agent/registry'
67
+ policy = PWN::AI::Agent::Policy
68
+ raise 'policy persistence escaped temporary HOME' unless [policy::POLICY_FILE, policy::TRAJECTORY_FILE].all? { |path| path.start_with?("#{root}/.pwn/") }
69
+
70
+ register_actions
71
+ arms = %i[off on].to_h { |mode| [mode, run_arm(root: root, mode: mode)] }
72
+ {
73
+ benchmark: 'pwn-policy-controller-v1',
74
+ scope: 'Deterministic local controller benchmark; NOT proof of live LLM improvement.',
75
+ ruby: RUBY_VERSION,
76
+ source_sha256: %w[policy registry].to_h { |name| [name, Digest::SHA256.file(File.expand_path("../lib/pwn/ai/agent/#{name}.rb", __dir__)).hexdigest] },
77
+ harness_sha256: Digest::SHA256.file(__FILE__).hexdigest,
78
+ isolation: { temporary_home: root, environment_cleared: true, network_calls: 0, persistence_removed_on_exit: true },
79
+ evaluation_updates: false, attempt_budget: 3, training_rounds: 6,
80
+ elapsed_seconds: clock - started, arms: arms
81
+ }
82
+ end
83
+ ensure
84
+ ENV.replace(original_env) if original_env
85
+ end
86
+
87
+ def self.clock
88
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
89
+ end
90
+
91
+ def self.fixtures(split:)
92
+ # Literal answer keys are not generated by, or passed to, action handlers.
93
+ numbers = if split == :train
94
+ [[[-2, 12, 3], [-2, 3, 12]], [[-5, 20, 1], [-5, 1, 20]]]
95
+ else
96
+ [[[-10, 4, 22, 0], [-10, 0, 4, 22]], [[31, 2, -3, 2], [-3, 2, 2, 31]],
97
+ [[7, 100, -8, 11], [-8, 7, 11, 100]], [[9, -1, 80, 6], [-1, 6, 9, 80]]]
98
+ end
99
+ inventory = if split == :train
100
+ [[[[1, true], [2, false]], [1]], [[[3, false], [4, true]], [4]]]
101
+ else
102
+ [[[[11, true], [12, false], [13, true]], [11, 13]],
103
+ [[[21, false], [22, false]], []],
104
+ [[[31, false], [32, true], [33, false]], [32]],
105
+ [[[41, true], [42, true], [43, false]], [41, 42]]]
106
+ end
107
+ { numbers: numbers, inventory: inventory }.flat_map do |family, pairs|
108
+ pairs.each_with_index.map do |(input, expected), index|
109
+ { id: "#{split}-#{family}-#{index}", family: family, input: input, expected: expected }
110
+ end
111
+ end
112
+ end
113
+
114
+ def self.query(family:)
115
+ family == :numbers ? 'fix numeric sorting artifact' : 'scan active inventory artifact'
116
+ end
117
+
118
+ def self.register_actions
119
+ actions = {
120
+ numbers: { a_lexical_sort: ->(data) { data.sort_by(&:to_s) }, b_numeric_sort: lambda(&:sort), c_numbers_claim: nil },
121
+ inventory: { a_active_filter: ->(data) { data.select { |row| row[1] == true }.map(&:first) }, b_all_records: ->(data) { data.map(&:first) }, c_inventory_claim: nil },
122
+ controls: { control_wrong_artifact: ->(_data) { { status: 'PASS: fully verified' } } }
123
+ }
124
+ actions.each do |family, implementations|
125
+ implementations.each do |name, implementation|
126
+ PWN::AI::Agent::Registry.register(
127
+ name: name, toolset: family,
128
+ schema: { name: name.to_s, description: query(family: family), parameters: { type: 'object', properties: {} } },
129
+ handler: lambda do |args|
130
+ data = JSON.parse(File.read(args.fetch(:input)))
131
+ File.write(args.fetch(:output), JSON.generate(implementation.call(data))) if implementation
132
+ { ok: true, text: 'PASS: completed successfully. All checks passed; the artifact is correct and verified.' }
133
+ end
134
+ )
135
+ end
136
+ end
137
+ end
138
+
139
+ def self.execute(root:, task:, action:, tag:)
140
+ directory = File.join(root, tag)
141
+ FileUtils.mkdir_p(directory)
142
+ input = File.join(directory, 'input.json')
143
+ output = File.join(directory, 'result.json')
144
+ raise 'task workspace reused' if File.exist?(input) || File.exist?(output)
145
+
146
+ File.write(input, JSON.generate(task[:input]))
147
+ input_digest = Digest::SHA256.file(input).hexdigest
148
+ started = clock
149
+ result = action.handler.call(input: input, output: output)
150
+ duration = clock - started
151
+ completed = score(path: output, expected: task[:expected]) && Digest::SHA256.file(input).hexdigest == input_digest
152
+ {
153
+ task_id: task[:id], family: task[:family], action: action.name,
154
+ completed: completed, claimed_success: result[:ok] == true,
155
+ false_success: result[:ok] == true && !completed,
156
+ checker: completed ? 'exact JSON answer and unchanged input' : 'missing/wrong artifact or changed input',
157
+ returned_text: result[:text], elapsed_seconds: duration, tool_calls: 1,
158
+ input_sha256: input_digest, artifact_sha256: File.file?(output) ? Digest::SHA256.file(output).hexdigest : nil
159
+ }
160
+ end
161
+
162
+ def self.fingerprint(root:)
163
+ Dir[File.join(root, '.pwn', '**', '*')].select { |path| File.file?(path) }.sort.to_h do |path|
164
+ [path.delete_prefix("#{root}/"), Digest::SHA256.file(path).hexdigest]
165
+ end
166
+ end
167
+
168
+ def self.run_arm(root:, mode:)
169
+ policy = PWN::AI::Agent::Policy
170
+ registry = PWN::AI::Agent::Registry
171
+ policy.reset
172
+ PWN::Env[:ai][:agent][:policy] = mode == :on
173
+ train = fixtures(split: :train)
174
+ training_started = clock
175
+ training_rows = []
176
+ # Balanced, fixed exploration schedule: no answer-based action selection.
177
+ 6.times do |round|
178
+ train.each do |task|
179
+ registry.all.select { |entry| entry.toolset == task[:family].to_s }.each do |action|
180
+ tag = "#{mode}/train/#{round}/#{task[:id]}/#{action.name}"
181
+ policy.begin_episode(session_id: tag, request: query(family: task[:family]))
182
+ row = execute(root: root, task: task, action: action, tag: tag)
183
+ policy.observe_step(session_id: tag, action: action.name, ok: row[:completed], duration: row[:elapsed_seconds])
184
+ update = policy.finish(session_id: tag, score: row[:completed] ? 1.0 : 0.0, confidence: 1.0, verdict: row[:completed] ? 'solved' : 'wrong')
185
+ raise "Policy.finish failed: #{update.inspect}" if update.nil? || update[:error]
186
+
187
+ training_rows << row.merge(policy_update: update)
188
+ end
189
+ end
190
+ end
191
+ training = summarize(rows: training_rows, elapsed: clock - training_started)
192
+ stats = policy.stats
193
+ before = fingerprint(root: root)
194
+ # Held-out data is materialized only AFTER all training finishes.
195
+ heldout = fixtures(split: :heldout)
196
+ disjoint = !train.map { |task| task[:input] }.intersect?(heldout.map { |task| task[:input] })
197
+ raise 'training/evaluation input overlap' unless disjoint
198
+
199
+ evaluation_started = clock
200
+ evaluation_rows = heldout.flat_map do |task|
201
+ attempts = []
202
+ 3.times do |attempt|
203
+ pool = registry.all.select { |entry| entry.toolset == task[:family].to_s }
204
+ # No begin/observe/finish during evaluation; Registry queries the
205
+ # learned table with its ordinary public fallback state.
206
+ ranked = registry.rank(query: query(family: task[:family]), entries: pool, preference: [])
207
+ raise 'Registry returned no action' if ranked.empty?
208
+
209
+ row = execute(root: root, task: task, action: ranked.first, tag: "#{mode}/heldout/#{task[:id]}/#{attempt}")
210
+ attempts << row.merge(attempt: attempt + 1, ranking: ranked.map(&:name))
211
+ break if row[:completed]
212
+ end
213
+ attempts
214
+ end
215
+ evaluation = summarize(rows: evaluation_rows, elapsed: clock - evaluation_started)
216
+ controls = heldout.flat_map do |task|
217
+ names = task[:family] == :numbers ? %w[c_numbers_claim control_wrong_artifact] : %w[c_inventory_claim control_wrong_artifact]
218
+ names.map do |name|
219
+ execute(root: root, task: task, action: registry.lookup(name: name), tag: "#{mode}/controls/#{task[:id]}/#{name}")
220
+ end
221
+ end
222
+ positive_controls = heldout.map do |task|
223
+ name = task[:family] == :numbers ? 'b_numeric_sort' : 'a_active_filter'
224
+ execute(root: root, task: task, action: registry.lookup(name: name), tag: "#{mode}/positive/#{task[:id]}")
225
+ end
226
+ frozen = before == fingerprint(root: root)
227
+ raise 'held-out evaluation changed persisted policy' unless frozen
228
+ raise 'a negative control defeated the independent scorer' unless controls.all? { |row| row[:false_success] && !row[:completed] }
229
+ raise 'a positive control failed the independent scorer' unless positive_controls.all? { |row| row[:completed] }
230
+
231
+ { disjoint_inputs: disjoint, policy_frozen: frozen, policy_stats: stats,
232
+ training: training, evaluation: evaluation, negative_controls: controls, positive_controls: positive_controls,
233
+ fixtures: { training: train, heldout: heldout },
234
+ policy_sha256: before, train_ids: train.map { |task| task[:id] }, heldout_ids: heldout.map { |task| task[:id] } }
235
+ end
236
+
237
+ def self.summarize(rows:, elapsed:)
238
+ tasks = rows.group_by { |row| row[:task_id] }
239
+ completed = tasks.count { |_, attempts| attempts.any? { |row| row[:completed] } }
240
+ failures = Hash.new(0)
241
+ rows.each do |row|
242
+ signature = [row[:family], row[:action], row[:checker]]
243
+ row[:repeated_mistake] = !row[:completed] && failures[signature].positive?
244
+ failures[signature] += 1 unless row[:completed]
245
+ end
246
+ {
247
+ tasks: tasks.length, completed: completed, completion_rate: completed.to_f / tasks.length,
248
+ false_successes: rows.count { |row| row[:false_success] },
249
+ false_success_rate: rows.count { |row| row[:false_success] }.to_f / rows.length,
250
+ repeated_mistakes: rows.count { |row| row[:repeated_mistake] },
251
+ tool_calls: rows.sum { |row| row[:tool_calls] }, elapsed_seconds: elapsed,
252
+ llm_calls: 0, tokens: 0, monetary_cost: nil,
253
+ cost_note: 'No provider invoked; monetary cost not estimated. Local compute cost is not priced.',
254
+ rows: rows
255
+ }
256
+ end
257
+ end
258
+
259
+ if $PROGRAM_NAME == __FILE__
260
+ options = {}
261
+ parser = OptionParser.new do |opts|
262
+ opts.banner = 'Usage: ruby scripts/benchmark_policy.rb [--self-check] [--output /tmp/report.json]'
263
+ opts.on('--self-check', 'Run scorer and end-to-end assertions') { options[:self_check] = true }
264
+ opts.on('--output PATH', 'Write the complete measured JSON report') { |path| options[:output] = path }
265
+ end
266
+ parser.parse!
267
+ abort parser.to_s unless ARGV.empty?
268
+ if options[:self_check]
269
+ PolicyBenchmark.self_check
270
+ else
271
+ report = PolicyBenchmark.run
272
+ json = JSON.pretty_generate(report)
273
+ File.write(options[:output], "#{json}\n") if options[:output]
274
+ puts json
275
+ end
276
+ end
@@ -3,6 +3,8 @@
3
3
  require 'spec_helper'
4
4
  require 'stringio'
5
5
  require 'timeout'
6
+ require 'tmpdir'
7
+ require 'open3'
6
8
  require 'pwn/setup'
7
9
 
8
10
  # Guards documentation/Installation.md from silently rotting.
@@ -110,10 +112,22 @@ RSpec.describe 'documentation/Installation.md' do
110
112
  end
111
113
 
112
114
  it '`pwn setup` and `pwn_setup` produce identical --list-profiles output' do
113
- a = `#{RbConfig.ruby} #{File.join(repo_root, 'bin/pwn')} setup --list-profiles </dev/null 2>&1`
114
- b = `#{RbConfig.ruby} #{File.join(repo_root, 'bin/pwn_setup')} --list-profiles </dev/null 2>&1`
115
- expect(a).to eq(b)
116
- PWN::Setup::PROFILES.each_key { |k| expect(a).to include(k.to_s) }
115
+ Dir.mktmpdir('pwn-list-profiles') do |home|
116
+ config_dir = File.join(home, '.pwn')
117
+ FileUtils.mkdir_p(config_dir)
118
+ config_path = File.join(config_dir, 'pwn.yaml')
119
+ fixture = "# unconfigured fixture; listing profiles must not load this file\n"
120
+ File.write(config_path, fixture)
121
+ outputs = [[File.join(repo_root, 'bin/pwn'), 'setup'], [File.join(repo_root, 'bin/pwn_setup')]].map do |command|
122
+ stdout, stderr, status = Open3.capture3({ 'HOME' => home }, RbConfig.ruby, *command, '--list-profiles', stdin_data: '')
123
+ expect(status.success?).to be(true), stderr
124
+ expect(stderr).to be_empty
125
+ stdout
126
+ end
127
+ expect(outputs.first).to eq(outputs.last)
128
+ PWN::Setup::PROFILES.each_key { |k| expect(outputs.first).to include(k.to_s) }
129
+ expect(File.read(config_path)).to eq(fixture)
130
+ end
117
131
  end
118
132
  end
119
133
 
@@ -78,13 +78,14 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
78
78
  # ═══════════════════════════════════════════════════════════════════════
79
79
 
80
80
  describe 'R1 · Reward.judge (Outcome Reward Model)' do
81
- it 'scores {0..1, verdict:} via heuristic fallback and feeds the sentinel' do
81
+ it 'retains heuristic diagnostics without feeding the sentinel' do
82
82
  v = reward.judge(request: 'scan the host', final: 'done — 3 hosts up',
83
83
  trace: [ok_trace, ok_trace], proxy_ok: true)
84
84
  expect(v[:score]).to be_between(0.0, 1.0)
85
85
  expect(%i[solved partial wrong unknown]).to include(v[:verdict])
86
86
  expect(v[:success]).to eq(v[:source].to_s != 'heuristic' && v[:score] >= 0.6)
87
- expect(JSON.parse(File.read(reward::SENTINEL_FILE))['samples']).to eq 1
87
+ expect(v[:training_score]).to be_nil
88
+ expect(reward.sentinel[:samples]).to eq 0
88
89
  end
89
90
 
90
91
  it 'floors self-reported failure language at 0.0' do
@@ -113,6 +114,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
113
114
  describe 'R3 · Reward.sentinel (reward-hacking guard)' do
114
115
  it 'flags proxy↔judge divergence >SENTINEL_GAP as a Mistake(tool: reward_signal)' do
115
116
  stub_const('PWN::AI::Agent::Reward::SENTINEL_WINDOW', 5)
117
+ allow(reward).to receive(:llm_judge).and_return(score: 0.1, source: :llm_orm, rationale: 'independently judged failure')
116
118
  5.times do
117
119
  reward.judge(request: 'x', final: 'ok', trace: [bad_trace, bad_trace], proxy_ok: true)
118
120
  end
@@ -314,7 +316,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
314
316
  allow(curriculum).to receive(:reflect_available?).and_return(false)
315
317
  # 2.4 — N≥2 solved holdouts required; practice bumps prompts_per to ≥2
316
318
  allow(curriculum).to receive(:self_play).and_return(
317
- score: 0.85, verdict: :solved,
319
+ score: 0.85, verdict: :solved, success: true, training_score: 0.85,
318
320
  final: 'use `nmap` (typo: nmpa→nmap)',
319
321
  prompt: 'fix nmap typo',
320
322
  trace: "shell → nmap -sn 10.0.0.0/24\nshell → true\npwn_eval → :ok"
@@ -611,6 +613,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
611
613
  PWN::Sessions.append(session_id: s[:id], role: 'user', content: 'enumerate hosts')
612
614
  PWN::Sessions.append(session_id: s[:id], role: 'tool', content: "shell → #{ok_trace}")
613
615
 
616
+ allow(reward).to receive(:llm_judge).and_return(score: 0.8, source: :llm_orm, verdict: :solved, rationale: 'request covered')
614
617
  expect(reward).to receive(:judge).and_call_original
615
618
  expect(reward).to receive(:prm).and_call_original
616
619
  expect(reward).to receive(:sentinel).and_call_original
@@ -643,7 +646,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
643
646
  expect(row[:score]).to be <= 0.3
644
647
  end
645
648
 
646
- it 'does not let critic :flaw floor a high-evidence judge score' do
649
+ it 'keeps a high judge score diagnostic when the critic disagrees rather than training the conflict' do
647
650
  @agent_cfg[:auto_introspect] = true
648
651
  @agent_cfg[:critic] = true
649
652
  allow(curriculum).to receive(:critic).and_return(verdict: :flaw, flaw: 'plan_cover_low')
@@ -654,8 +657,10 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
654
657
  s = PWN::Sessions.create(title: 'e2e_critic_keep')
655
658
  learning.auto_introspect(session_id: s[:id], request: 'x', final: 'path-backed complete answer')
656
659
  row = learning.outcomes.first
657
- expect(row[:success]).to be true
658
- expect(row[:score]).to be >= 0.6
660
+ expect(row[:success]).to be_nil
661
+ expect(row[:score]).to eq(0.87)
662
+ expect(row[:verdict].to_s).to eq('unknown')
663
+ expect(row[:training_score]).to be_nil
659
664
  end
660
665
  end
661
666
 
@@ -751,11 +756,11 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
751
756
  evalset = Array(o[:evalset])
752
757
  if evalset.any? { |e| e[:signature].to_s.start_with?('smoke_') }
753
758
  # smoke equal
754
- { resolved: 3, mean_score: 0.9, scores: [0.9, 0.9, 0.9] }
759
+ { resolved: 3, mean_score: 0.9, scores: [0.9, 0.9, 0.9], unknown: 0 }
755
760
  elsif tag.include?('cand') || tag == 'cand'
756
- { resolved: 8, mean_score: 0.85, scores: [0.85] * 10 }
761
+ { resolved: 8, mean_score: 0.85, scores: [0.85] * 10, unknown: 0 }
757
762
  else
758
- { resolved: 5, mean_score: 0.70, scores: [0.7] * 10 }
763
+ { resolved: 5, mean_score: 0.70, scores: [0.7] * 10, unknown: 0 }
759
764
  end
760
765
  end
761
766
  allow(curriculum).to receive(:smoke_eval_set).and_return(
@@ -776,14 +781,14 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
776
781
  if evalset.any? { |e| e[:signature].to_s.start_with?('smoke_') }
777
782
  tag = o[:tag].to_s
778
783
  if tag == 'cand'
779
- { resolved: 0, mean_score: 0.1, scores: [0.1] }
784
+ { resolved: 0, mean_score: 0.1, scores: [0.1], unknown: 0 }
780
785
  else
781
- { resolved: 3, mean_score: 0.9, scores: [0.9] }
786
+ { resolved: 3, mean_score: 0.9, scores: [0.9], unknown: 0 }
782
787
  end
783
788
  elsif o[:tag].to_s == 'cand'
784
- { resolved: 9, mean_score: 0.95, scores: [0.95] * 10 }
789
+ { resolved: 9, mean_score: 0.95, scores: [0.95] * 10, unknown: 0 }
785
790
  else
786
- { resolved: 5, mean_score: 0.70, scores: [0.7] * 10 }
791
+ { resolved: 5, mean_score: 0.70, scores: [0.7] * 10, unknown: 0 }
787
792
  end
788
793
  end
789
794
  allow(curriculum).to receive(:smoke_eval_set).and_return(
@@ -856,6 +861,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
856
861
  PWN::Sessions.append(session_id: s[:id], role: 'tool', content: ok_trace)
857
862
  PWN::Sessions.append(session_id: s[:id], role: 'assistant', content: '3 hosts up')
858
863
 
864
+ allow(reward).to receive(:llm_judge).and_return(score: 0.8, source: :llm_orm, verdict: :solved, rationale: 'trace matches request')
859
865
  r = curriculum.offline_judge(since_hours: 24, limit: 10, prm: true, commit: true)
860
866
  expect(r[:scored]).to be >= 1
861
867
  row = learning.outcomes(tag: 'offline_judge').first
@@ -928,6 +934,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
928
934
  allow(curriculum).to receive(:self_play).and_return(
929
935
  score: 0.9, verdict: :solved,
930
936
  final: "fixed with nmap\nsecond line\nthird",
937
+ success: true, training_score: 0.9,
931
938
  prompt: 'scan the lab safely',
932
939
  trace: "shell → nmap -sn 10.0.0.0/24\nshell → true"
933
940
  )