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
@@ -49,6 +49,273 @@ describe PWN::AI::Agent::Curriculum do
49
49
  expect(r[:version]).to eq 1
50
50
  end
51
51
 
52
+ describe '.offline_judge' do
53
+ let(:reward) { PWN::AI::Agent::Reward }
54
+ let(:learning) { PWN::AI::Agent::Learning }
55
+ let(:outcome) do
56
+ reward.resolve_outcome(outcome: { score: 0.95, source: :heuristic, confidence: 0.35, rationale: 'overlap only' })
57
+ end
58
+
59
+ before do
60
+ allow(PWN::Sessions).to receive(:list).and_return([{ id: 'curriculum-test' }])
61
+ allow(PWN::Sessions).to receive(:load).and_return([
62
+ { role: 'user', content: 'check the result' },
63
+ { role: 'assistant', content: 'PLAN: check result p(success)=0.8' },
64
+ { role: 'assistant', content: 'result checked' }
65
+ ])
66
+ allow(learning).to receive(:outcomes).and_return([])
67
+ allow(learning).to receive(:note_outcome)
68
+ allow(reward).to receive(:judge).and_return(outcome)
69
+ allow(reward).to receive(:prm)
70
+ allow(reward).to receive(:warm_sentinel)
71
+ allow(reward).to receive(:scrub_preferences)
72
+ allow(reward).to receive(:generator_mix)
73
+ allow(described_class).to receive(:calibrate)
74
+ allow(described_class).to receive(:reclassify_backlog).and_return({ reclassified: 0 })
75
+ allow(described_class).to receive(:practice_kpi)
76
+ allow(described_class).to receive(:log)
77
+ end
78
+
79
+ it 'preserves unknown outcomes without PRM or calibration training' do
80
+ result = described_class.offline_judge
81
+
82
+ expect(reward).not_to have_received(:prm)
83
+ expect(described_class).not_to have_received(:calibrate)
84
+ expect(learning).to have_received(:note_outcome).with(hash_including(outcome: outcome, tags: %w[offline_judge auto unknown]))
85
+ expect(result[:results].first).to include(outcome)
86
+ end
87
+
88
+ it 'retries previously unknown outcomes instead of treating their diagnostic scores as labels' do
89
+ allow(learning).to receive(:outcomes).and_return([outcome.merge(session_id: 'curriculum-test', tags: ['offline_judge'])])
90
+
91
+ result = described_class.offline_judge
92
+
93
+ expect(result[:scored]).to eq(1)
94
+ expect(reward).to have_received(:judge)
95
+ end
96
+
97
+ it 'persists unknown verdict and confidence in the real learning ledger' do
98
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.mktmpdir, 'learning.jsonl'))
99
+ allow(learning).to receive(:note_outcome).and_call_original
100
+ allow(learning).to receive(:outcomes).and_call_original
101
+
102
+ described_class.offline_judge
103
+
104
+ expect(learning.outcomes.first).to include(
105
+ verdict: 'unknown', confidence: 0.35, training_score: nil,
106
+ decision_version: 1, status: 'unverified'
107
+ )
108
+ end
109
+
110
+ context 'with an error outcome' do
111
+ let(:outcome) { reward.resolve_outcome(outcome: { source: :error, error: 'judge unavailable' }) }
112
+
113
+ it 'keeps missing scores unknown without training a failure label' do
114
+ result = described_class.offline_judge
115
+
116
+ expect(result[:results].first).to include(outcome)
117
+ expect(reward).not_to have_received(:prm)
118
+ expect(described_class).not_to have_received(:calibrate)
119
+ expect(learning).to have_received(:note_outcome).with(hash_including(outcome: outcome))
120
+ end
121
+ end
122
+
123
+ context 'with a known failure' do
124
+ let(:outcome) { reward.resolve_outcome(outcome: { score: 0.4, source: :llm_orm }) }
125
+
126
+ it 'calibrates partial credit against failure rather than a fractional success label' do
127
+ described_class.offline_judge
128
+
129
+ expect(described_class).to have_received(:calibrate).with(hash_including(actual: 0.0))
130
+ expect(reward).to have_received(:prm)
131
+ expect(learning).to have_received(:note_outcome).with(hash_including(outcome: outcome, tags: %w[offline_judge auto partial]))
132
+ end
133
+ end
134
+
135
+ context 'with a known outcome' do
136
+ let(:outcome) { reward.resolve_outcome(outcome: { score: 0.65, source: :llm_orm, confidence: 0.85 }) }
137
+
138
+ it 'calibrates predicted success against the resolved label, not the diagnostic score' do
139
+ described_class.offline_judge
140
+
141
+ expect(described_class).to have_received(:calibrate).with(hash_including(predicted: 0.8, actual: 1.0))
142
+ expect(reward).to have_received(:prm)
143
+ expect(learning).to have_received(:note_outcome).with(hash_including(outcome: outcome))
144
+ end
145
+
146
+ it 'does not annotate or train sessions when commit is false' do
147
+ described_class.offline_judge(commit: false)
148
+
149
+ expect(reward).not_to have_received(:prm)
150
+ expect(learning).not_to have_received(:note_outcome)
151
+ expect(described_class).not_to have_received(:calibrate)
152
+ end
153
+ end
154
+ end
155
+
156
+ describe 'practice reward decisions' do
157
+ let(:reward) { PWN::AI::Agent::Reward }
158
+ let(:outcome) { reward.resolve_outcome(outcome: { score: 0.95, source: :heuristic, confidence: 0.35 }) }
159
+
160
+ before do
161
+ allow(PWN::Sessions).to receive(:create).and_return({ id: 'practice-test' })
162
+ allow(PWN::Sessions).to receive(:load).and_return([{ role: 'tool', content: 'shell: checked result with a real tool trace' }])
163
+ allow(PWN::AI::Agent::Loop).to receive(:run).and_return('candidate answer')
164
+ allow(reward).to receive(:judge).and_return(outcome)
165
+ end
166
+
167
+ it 'keeps the entire resolved outcome on self-play trials' do
168
+ run = described_class.send(:self_play, prompt: 'check result', tag: 'test')
169
+
170
+ expect(run).to include(outcome)
171
+ expect(run).to include(final: 'candidate answer', session_id: 'practice-test')
172
+ expect(run[:trace]).to include('shell: checked result')
173
+ end
174
+
175
+ it 'retains failed trial context with an unknown decision when judging errors' do
176
+ allow(reward).to receive(:judge).and_raise(StandardError, 'judge unavailable')
177
+
178
+ run = described_class.send(:self_play, prompt: 'check result', tag: 'test')
179
+
180
+ expect(run).to include(
181
+ session_id: 'practice-test', final: 'candidate answer', prompt: 'check result',
182
+ source: :error, verdict: :unknown, success: nil, training_score: nil,
183
+ decision_version: 1, error: 'judge unavailable'
184
+ )
185
+ end
186
+
187
+ context 'when replaying model evaluations' do
188
+ let(:failed) { reward.resolve_outcome(outcome: { score: 0.2, source: :llm_orm }) }
189
+ let(:solved) { reward.resolve_outcome(outcome: { score: 0.65, source: :llm_orm }) }
190
+ let(:evalset) { [{ prompt: 'unknown check' }, { prompt: 'failed check' }, { prompt: 'solved check' }] }
191
+
192
+ before do
193
+ allow(reward).to receive(:judge).and_return(outcome, failed, solved)
194
+ end
195
+
196
+ it 'counts canonical successes and excludes unknown scores from the evaluation mean' do
197
+ result = described_class.send(:replay_on_detailed, tag: 'test-model', evalset: evalset)
198
+
199
+ expect(result[:resolved]).to eq(1)
200
+ expect(result[:mean_score]).to eq(0.425)
201
+ expect(result[:unknown]).to eq(1)
202
+ expect(result[:outcomes]).to match([include(outcome), include(failed), include(solved)])
203
+ end
204
+
205
+ it 'uses the same trusted decision for legacy replay counts' do
206
+ expect(described_class.send(:replay_on, tag: 'test-model', evalset: evalset.first(2))).to eq(0)
207
+ expect(described_class.send(:replay_on, tag: 'test-model', evalset: evalset.last(1))).to eq(1)
208
+ end
209
+ end
210
+
211
+ context 'when gating a candidate model' do
212
+ let(:baseline_outcome) { outcome }
213
+ let(:candidate_outcome) { reward.resolve_outcome(outcome: { score: 0.65, source: :llm_orm }) }
214
+
215
+ before do
216
+ allow(described_class).to receive(:self_play) do |opts|
217
+ opts[:tag] == 'gate:baseline' ? baseline_outcome : candidate_outcome
218
+ end
219
+ end
220
+
221
+ it 'refuses promotion when a comparison contains unknown outcomes' do
222
+ result = described_class.send(:ab_gate_v2, baseline: 'baseline', candidate: 'candidate', evalset: [{ prompt: 'check' }])
223
+
224
+ expect(result[:promote]).to be(false)
225
+ expect(result[:outcomes_known]).to be(false)
226
+ end
227
+
228
+ context 'with all outcomes known' do
229
+ let(:baseline_outcome) { reward.resolve_outcome(outcome: { score: 0.2, source: :llm_orm }) }
230
+
231
+ it 'can promote a trusted improvement over known failures' do
232
+ result = described_class.send(:ab_gate_v2, baseline: 'baseline', candidate: 'candidate', evalset: [{ prompt: 'check' }])
233
+
234
+ expect(result[:promote]).to be(true)
235
+ expect(result[:outcomes_known]).to be(true)
236
+ end
237
+ end
238
+ end
239
+
240
+ context 'when practising a mistake' do
241
+ before do
242
+ stub_const('PWN::AI::Agent::Curriculum::CURRICULUM_DIR', Dir.mktmpdir)
243
+ allow(PWN::AI::Agent::Mistakes).to receive(:top).and_return([{ signature: 'test', tool: 'shell', count: 3 }])
244
+ allow(PWN::AI::Agent::Mistakes).to receive(:resolve)
245
+ allow(PWN::AI::Agent::Mistakes).to receive(:operator_inbox).and_return({ count: 0, items: [] })
246
+ allow(reward).to receive(:record_preference)
247
+ allow(reward).to receive(:generator_mix)
248
+ allow(described_class).to receive(:generate_reproducers).and_return(['first check', 'second check'])
249
+ allow(described_class).to receive(:load_cooldown).and_return({})
250
+ allow(described_class).to receive(:save_cooldown)
251
+ allow(described_class).to receive(:practice_kpi)
252
+ allow(described_class).to receive(:log)
253
+ end
254
+
255
+ it 'does not resolve or create winning trajectories from high-scoring unknowns' do
256
+ cooldown = { 'test' => { 'fail_nights' => 1 } }
257
+ allow(described_class).to receive(:load_cooldown).and_return(cooldown)
258
+ result = described_class.practice(limit: 1)
259
+
260
+ expect(result[:resolved]).to eq(0)
261
+ expect(cooldown).to eq('test' => { 'fail_nights' => 1 })
262
+ expect(result[:results].first[:mean_score]).to be_nil
263
+ expect(PWN::AI::Agent::Mistakes).not_to have_received(:resolve)
264
+ expect(reward).not_to have_received(:record_preference)
265
+ expect(result[:results].first[:runs]).to all(include(outcome))
266
+ end
267
+
268
+ context 'when the evaluator errors' do
269
+ it 'leaves failure cooldown unchanged rather than parking an unknown-only practice night' do
270
+ cooldown = { 'test' => { 'fail_nights' => 2, 'last_mean' => 0.1 } }
271
+ original = Marshal.load(Marshal.dump(cooldown))
272
+ allow(described_class).to receive(:load_cooldown).and_return(cooldown)
273
+ allow(reward).to receive(:judge).and_raise(StandardError, 'judge unavailable')
274
+ allow(PWN::AI::Agent::Mistakes).to receive(:park)
275
+
276
+ result = described_class.practice(limit: 1)
277
+
278
+ expect(cooldown).to eq(original)
279
+ expect(result[:results].first).to include(resolved: false, mean_score: nil)
280
+ expect(described_class).to have_received(:save_cooldown).with(cooldown: original)
281
+ expect(PWN::AI::Agent::Mistakes).not_to have_received(:park)
282
+ end
283
+ end
284
+
285
+ it 'still parks repeated verified failures without letting unknown scores mask them' do
286
+ failed = reward.resolve_outcome(outcome: {
287
+ score: 0.0, source: :heuristic,
288
+ verification: { checks: [{ criterion: 'required report', passed: false, evidence: 'report absent' }] }
289
+ })
290
+ cooldown = {}
291
+ allow(described_class).to receive(:load_cooldown).and_return(cooldown)
292
+ allow(PWN::AI::Agent::Mistakes).to receive(:park)
293
+ allow(described_class).to receive(:self_play).with(hash_including(prompt: 'first check')).and_return(failed)
294
+ allow(described_class).to receive(:self_play).with(hash_including(prompt: 'second check')).and_return(outcome)
295
+
296
+ described_class::COOLDOWN_FAIL_NIGHTS.times do
297
+ result = described_class.practice(limit: 1)
298
+ expect(result[:results].first).to include(resolved: false, mean_score: 0.0)
299
+ end
300
+
301
+ expect(cooldown['test']).to include('fail_nights' => described_class::COOLDOWN_FAIL_NIGHTS, 'parked' => true)
302
+ expect(PWN::AI::Agent::Mistakes).to have_received(:park).with(hash_including(signature: 'test'))
303
+ end
304
+
305
+ context 'with trusted success' do
306
+ let(:outcome) { reward.resolve_outcome(outcome: { score: 0.65, source: :llm_orm, confidence: 0.85 }) }
307
+
308
+ it 'resolves holdouts from the canonical success decision' do
309
+ result = described_class.practice(limit: 1)
310
+
311
+ expect(result[:resolved]).to eq(1)
312
+ expect(PWN::AI::Agent::Mistakes).to have_received(:resolve)
313
+ expect(reward).to have_received(:record_preference)
314
+ end
315
+ end
316
+ end
317
+ end
318
+
52
319
  it 'critic returns pass when disabled' do
53
320
  r = described_class.critic(request: 'x', final: 'y')
54
321
  expect(r[:verdict]).to eq :pass
@@ -24,4 +24,16 @@ describe PWN::AI::Agent::Engagement do
24
24
  expect(described_class.in_scope?(ip: '10.0.0.5')).to eq(true)
25
25
  end
26
26
  end
27
+
28
+ it 'enforces ~/.pwn/roe.yaml allow/deny when present' do
29
+ Dir.mktmpdir do |dir|
30
+ allow(Dir).to receive(:home).and_return(dir)
31
+ FileUtils.mkdir_p(File.join(dir, '.pwn'))
32
+ File.write(File.join(dir, '.pwn', 'roe.yaml'), "targets_allow:\n - 10.0.0.0/8\ntargets_deny:\n - evil.example\ntechniques_deny:\n - dos\n")
33
+ expect(described_class.in_scope?(host: '10.1.2.3')).to eq(true)
34
+ expect(described_class.in_scope?(host: 'evil.example')).to eq(false)
35
+ deny = described_class.deny_if_out_of_scope(command: 'launch dos flood')
36
+ expect(deny[:code]).to eq('ROE_DENY')
37
+ end
38
+ end
27
39
  end
@@ -332,9 +332,9 @@ describe PWN::AI::Agent::Learning do
332
332
  allow(PWN::AI::Agent::Reward).to receive(:proxy_distrust).and_return(1.0)
333
333
  allow(PWN::AI::Agent::Reward).to receive(:sentinel).and_return(nil)
334
334
  stats = PWN::AI::Agent::Learning.stats
335
- # unweighted mean = 0.55; ORM-weighted mean closer to 0.2
336
- expect(stats[:judge_mean]).to be < 0.45
337
- expect(stats[:judge_mean]).to be > 0.2
335
+ # Unverified heuristic guesses remain diagnostic, not evaluated tasks.
336
+ expect(stats[:judge_mean]).to eq(0.2)
337
+ expect(stats[:unknown_outcomes]).to eq(8)
338
338
  expect(stats[:adjusted_success_rate]).to be_within(0.05).of(stats[:judge_mean])
339
339
  ensure
340
340
  FileUtils.rm_rf(tmp) if defined?(tmp) && tmp
@@ -356,6 +356,84 @@ describe PWN::AI::Agent::Learning do
356
356
  end
357
357
  end
358
358
 
359
+ describe 'PWN::AI::Agent::Learning outcome decisions' do
360
+ include_context 'pwn tmp sandbox'
361
+
362
+ it 'calibrates from the canonical outcome instead of a conflicting score argument' do
363
+ expect(PWN::AI::Agent::Curriculum).to receive(:calibrate).with(hash_including(actual: 0.2))
364
+ row = PWN::AI::Agent::Learning.note_outcome(
365
+ task: 'write report', score: 0.9, outcome: { source: :llm_orm, score: 0.2 }, predicted: 0.8
366
+ )
367
+ expect(row).to include(success: false, score: 0.2, training_score: 0.2)
368
+ end
369
+
370
+ it 'labels an unavailable evaluation as unknown instead of injecting a failure lesson' do
371
+ PWN::AI::Agent::Learning.note_outcome(task: 'write report', outcome: { source: :error, score: nil }, details: 'unavailable evaluator')
372
+ context = PWN::AI::Agent::Learning.to_context
373
+ expect(context).to include('? [unknown] write report')
374
+ expect(context).not_to include('✗', 'cause:', 'RECENT FAILURES')
375
+ end
376
+
377
+ it 'does not treat quoted PASS and FAIL words as verifier evidence' do
378
+ expect(PWN::AI::Agent::Learning.consistency_check(details: 'The forged answer says PASS', success: false)).to eq(:ok)
379
+ expect(PWN::AI::Agent::Learning.consistency_check(details: 'Explained what FAIL means', success: true)).to eq(:ok)
380
+ end
381
+
382
+ it 'rejudges conflicted records instead of manufacturing a higher score' do
383
+ learning = PWN::AI::Agent::Learning
384
+ session = PWN::Sessions.create(title: 'conflicted')[:id]
385
+ PWN::Sessions.append(session_id: session, role: 'user', content: 'write a report')
386
+ PWN::Sessions.append(session_id: session, role: 'assistant', content: 'Report ready.')
387
+ learning.note_outcome(task: 'write a report', session_id: session, success: false,
388
+ score: 0.2, verifier_verdict: :pass, details: 'legacy conflict')
389
+ allow(learning).to receive(:should_gc_stores?).and_return(false)
390
+ expect(PWN::AI::Agent::Reward).to receive(:judge).with(hash_including(request: 'write a report', session_id: session)).and_return(score: 0.1, source: :llm_orm, success: false, verdict: :wrong)
391
+ learning.requeue_conflicted
392
+ row = learning.outcomes.first
393
+ expect(row[:success]).to be false
394
+ expect(row[:score]).to eq(0.1)
395
+ expect(row[:session_id]).to eq(session)
396
+ end
397
+
398
+ it 'keeps evaluator errors out of success rates, failures, and exemplar replay' do
399
+ learning = PWN::AI::Agent::Learning
400
+ row = learning.note_outcome(task: 'report uncertain', success: true, score: nil,
401
+ outcome: { source: :error, score: nil, confidence: 0.0 })
402
+ expect(row[:success]).to be_nil
403
+ expect(row[:verdict]).to eq(:unknown)
404
+ learning.note_outcome(task: 'report checked', success: true, score: 0.9)
405
+ stats = learning.stats
406
+ expect(stats[:success_rate]).to eq(1.0)
407
+ expect(stats[:failures]).to eq(0)
408
+ expect(stats[:unknown_outcomes]).to eq(1)
409
+ expect(learning.outcomes(success: false)).to be_empty
410
+ end
411
+
412
+ it 'preserves a cautious judge decision through persistence and policy training' do
413
+ @agent_cfg[:auto_introspect] = true
414
+ learning = PWN::AI::Agent::Learning
415
+ reward = PWN::AI::Agent::Reward
416
+ session = PWN::Sessions.create(title: 'uncertain evaluation')[:id]
417
+ allow(PWN::AI::Agent::Curriculum).to receive(:critic).and_return(verdict: :pass)
418
+ allow(learning).to receive(:should_gc_stores?).and_return(false)
419
+ allow(learning).to receive(:reflect)
420
+ allow(reward).to receive(:judge).and_return(
421
+ score: 0.9, source: :heuristic, confidence: 0.35, verdict: :unknown,
422
+ success: false, training_score: nil, rationale: 'unverified overlap'
423
+ )
424
+ expect(PWN::AI::Agent::Policy).to receive(:finish).with(hash_including(score: nil, verdict: :unknown))
425
+ expect(learning).not_to receive(:fold_judge_into_metrics)
426
+ expect(reward).not_to receive(:prm)
427
+ learning.auto_introspect(session_id: session, request: 'write a report', final: 'Report ready.', inline: true)
428
+ row = learning.outcomes.first
429
+ expect(row[:success]).not_to be true
430
+ expect(row[:verdict].to_s).to eq('unknown')
431
+ expect(row[:training_score]).to be_nil
432
+ expect(row[:confidence]).to eq(0.35)
433
+ expect(row[:judge_source].to_s).to eq('heuristic')
434
+ end
435
+ end
436
+
359
437
  describe 'PWN::AI::Agent::Learning conflicted outcomes' do
360
438
  it 'tags verifier/judge conflicts and keeps them out of RECENT FAILURES' do
361
439
  Dir.mktmpdir do |dir|
@@ -16,6 +16,51 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
16
16
  end
17
17
 
18
18
  describe 'RL-adjacent loop contracts' do # rubocop:disable Metrics/BlockLength
19
+ describe 'live policy tool exposure' do
20
+ include_context 'pwn tmp sandbox'
21
+
22
+ it 'refreshes schemas after observation before the next engine call without changing routing scope' do
23
+ policy = PWN::AI::Agent::Policy
24
+ registry = PWN::AI::Agent::Registry
25
+ request = 'what color is a passion fruit?'
26
+ session_id = PWN::Sessions.create(title: 'context refresh')[:id]
27
+ policy.reset
28
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
29
+ allow(described_class).to receive(:should_auto_introspect?).and_return(false)
30
+ allow(PWN::AI::Agent::Dispatch).to receive(:call).and_return('{"success":true,"result":{"stdout":"purple","exitstatus":0}}')
31
+ scopes = []
32
+ allow(registry).to receive(:definitions) do |opts|
33
+ scopes << opts
34
+ observed = Array(policy.current_episode&.dig(:steps)).any?
35
+ names = observed ? %w[pwn_eval shell] : %w[shell pwn_eval]
36
+ names.map { |name| { type: 'function', function: { name: name } } }
37
+ end
38
+ seen = []
39
+ allow(described_class).to receive(:call_engine) do |opts|
40
+ seen << Array(opts[:tools]).map { |tool| tool.dig(:function, :name) }
41
+ if seen.length == 1
42
+ { role: 'assistant', tool_calls: [{ id: 'observe_color', type: 'function', function: { name: 'shell', arguments: '{"command":"printf purple"}' } }] }
43
+ else
44
+ raise 'unexpected extra engine call' if seen.length > 2
45
+
46
+ { role: 'assistant', content: 'Purple when ripe.', tool_calls: [] }
47
+ end
48
+ end
49
+ result = described_class.run(request: request, session_id: session_id, system_role_content: 'test system', enabled_toolsets: ['shell'], core_only: false)
50
+ expect(result).to eq('Purple when ripe.')
51
+ expect(seen).to eq([%w[shell pwn_eval], %w[pwn_eval shell]])
52
+ expect(scopes).to all(include(relevance: request, enabled: ['shell'], core_only: false, intent: described_class.request_intent(request: request)))
53
+ end
54
+ end
55
+
56
+ it 'passes parsed action arguments and result classification into policy observation' do
57
+ allow(PWN::AI::Agent::Metrics).to receive(:record)
58
+ allow(PWN::AI::Agent::Reward).to receive(:semantic_ok).and_return(semantic_ok: true, shape: :success)
59
+ expected = hash_including(args: { 'operation' => 'read', 'path' => '/tmp/report' }, result_type: :success)
60
+ expect(PWN::AI::Agent::Policy).to receive(:observe_step).with(expected)
61
+ described_class.send(:record_metrics, name: 'file', raw: '{"success":true}', args: '{"operation":"read","path":"/tmp/report"}')
62
+ end
63
+
19
64
  it 'spins on engine HTTP wait and still dispatches on_tool (debug must not hide the TUI)' do
20
65
  src = File.read(described_class.method(:run).source_location.first)
21
66
  expect(src).to match(/spinner:\s*true/)
@@ -436,6 +481,12 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
436
481
  expect(src).to match(/engine hop failed|engine_blip/)
437
482
  end
438
483
 
484
+ it 'returns a billing line on OpenAI quota 429 instead of crashing the REPL' do
485
+ src = File.read(described_class.method(:run).source_location.first)
486
+ expect(src).to match(/quota_exhausted\?/)
487
+ expect(src).to match(/quota_message/)
488
+ end
489
+
439
490
  it 'compacts tool history for every engine, not only local' do
440
491
  src = File.read(described_class.method(:run).source_location.first)
441
492
  expect(src).to match(/compact_history!\(messages: messages\)/)
@@ -35,6 +35,50 @@ describe PWN::AI::Agent::Metrics do
35
35
  expect(rate).to be > 0.2
36
36
  end
37
37
 
38
+ describe '.scoreboard' do
39
+ before do
40
+ allow(described_class).to receive(:summary).and_return([])
41
+ allow(described_class).to receive(:calibration).and_return(mean_actual: 0.4)
42
+ end
43
+
44
+ it 'does not override a rejected outcome with a high raw score' do
45
+ allow(PWN::AI::Agent::Learning).to receive(:outcomes).with(limit: 200).and_return([
46
+ { success: true, score: 0.8, training_score: 0.8, decision_version: 1 },
47
+ { success: false, score: 0.99, training_score: 0.2, decision_version: 1 }
48
+ ])
49
+
50
+ expect(described_class.scoreboard[:task_ok]).to eq(0.5)
51
+ end
52
+
53
+ it 'excludes canonical unknown outcomes from the task success denominator' do
54
+ Dir.mktmpdir do |dir|
55
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(dir, 'learning.jsonl'))
56
+ allow(PWN::AI::Agent::Learning).to receive(:promote_process_lesson)
57
+ [
58
+ { score: 0.8, source: :llm_orm },
59
+ { score: 0.2, source: :llm_orm },
60
+ { score: 0.99, source: :heuristic },
61
+ { score: nil, source: :self_report }
62
+ ].each do |outcome|
63
+ PWN::AI::Agent::Learning.note_outcome(task: 'Check outcome', outcome: outcome)
64
+ end
65
+
66
+ expect(described_class.scoreboard[:task_ok]).to eq(0.5)
67
+ end
68
+ end
69
+
70
+ it 'returns no task rate when every outcome is unknown or unverified' do
71
+ allow(PWN::AI::Agent::Learning).to receive(:outcomes).with(limit: 200).and_return([
72
+ { success: nil, score: 0.99 },
73
+ { success: false, score: 0.99, verdict: 'unknown' },
74
+ { success: true, score: 0.99, status: 'unverified' },
75
+ { success: true, score: 0.99, decision_version: 1, training_score: nil }
76
+ ])
77
+
78
+ expect(described_class.scoreboard[:task_ok]).to be_nil
79
+ end
80
+ end
81
+
38
82
  it 'temperature-scales overconfident predictions toward realised actual' do
39
83
  stub_const('PWN::AI::Agent::Metrics::METRICS_FILE', File.join(Dir.mktmpdir, 'metrics.json'))
40
84
  described_class.reset
@@ -216,4 +216,14 @@ describe PWN::AI::Agent::Mistakes do
216
216
  expect(classes).to eq(rows.map { |r| r['class'] })
217
217
  expect(classes.uniq.length).to eq(3)
218
218
  end
219
+
220
+ it 'gives pull-access-denied an auth family hint, never ls-the-parent' do
221
+ stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(Dir.mktmpdir, 'mistakes.json'))
222
+ described_class.reset
223
+ described_class.record(tool: 'shell', error: 'pull access denied for library/foo')
224
+ hint = described_class.correction_hint(tool: 'shell', error: 'pull access denied for library/foo')
225
+ expect(hint).to match(/credential|registry/i)
226
+ expect(hint).not_to match(%r{ls/test -e the parent})
227
+ expect(described_class.family(error: 'pull access denied')).to eq('auth_denied')
228
+ end
219
229
  end
@@ -271,6 +271,109 @@ describe PWN::AI::Agent::Policy do
271
271
  described_class.reset
272
272
  FileUtils.remove_entry(tmp) if tmp && Dir.exist?(tmp)
273
273
  end
274
+ end
275
+
276
+ describe PWN::AI::Agent::Policy do
277
+ describe 'outcome-gated contextual learning' do
278
+ around do |example|
279
+ Dir.mktmpdir do |tmp|
280
+ @policy_dir = tmp
281
+ example.run
282
+ ensure
283
+ described_class.attach_episode!(episode: nil)
284
+ end
285
+ end
286
+
287
+ before do
288
+ stub_const('PWN::AI::Agent::Policy::POLICY_FILE', File.join(@policy_dir, 'policy.json'))
289
+ stub_const('PWN::AI::Agent::Policy::TRAJECTORY_FILE', File.join(@policy_dir, 'policy_traj.jsonl'))
290
+ allow(described_class).to receive(:enabled?).and_return(true)
291
+ end
292
+
293
+ it 'does not train or count an unjudged episode even when step costs accrue' do
294
+ described_class.begin_episode(request: 'inspect host')
295
+ 10.times { described_class.observe_step(action: 'shell', ok: true) }
296
+ report = described_class.finish(score: nil, proxy_ok: true)
297
+
298
+ expect(report).to include(td_updates: 0, pg_updates: 0, return: nil)
299
+ expect(described_class.load).to include(q: {}, h: {}, visits: {}, returns: [])
300
+ expect(described_class.current_episode).to be_nil
301
+ expect(described_class.trajectories.first).to include(score: nil, return: nil)
302
+ (described_class::COLD_EPISODES - 1).times do
303
+ described_class.begin_episode(request: 'inspect host')
304
+ described_class.finish(score: nil)
305
+ end
306
+ expect(described_class.warmup!).to include(td_updates: 0, replayed: 0)
307
+ expect(described_class.stats[:n_episodes]).to eq(0)
308
+ expect(described_class.evaluate[:n]).to eq(0)
309
+ expect(described_class.episode_budget_met?).to be(false)
310
+ end
311
+
312
+ it 'backs off sparse, missing, or mismatched context to broad scores' do
313
+ described_class.begin_episode(request: 'inspect')
314
+ described_class.observe_step(action: 'file', operation: 'read', ok: true)
315
+ state = described_class.current_state
316
+ context = described_class.current_context_state
317
+ table = described_class.load
318
+ table[:q][state.to_sym] = { alpha: 0.5, beta: 0.0 }
319
+ table[:visits][state.to_sym] = { alpha: 5, beta: 5 }
320
+ table[:q][context.to_sym] = { alpha: -1.0, beta: 1.0 }
321
+ table[:visits][context.to_sym] = { alpha: 2, beta: 2 }
322
+ described_class.save(table: table)
323
+ options = { state: state, actions: %w[alpha beta], epsilon: 0.0 }
324
+
325
+ expect(described_class.recommend(options)[:action]).to eq('alpha')
326
+ expect(described_class.advantage(state: state, action: 'beta', context_state: context)).to eq(described_class.advantage(state: state, action: 'beta'))
327
+ table[:visits][context.to_sym] = { alpha: 3, beta: 3 }
328
+ described_class.save(table: table)
329
+ expect(described_class.recommend(options)[:action]).to eq('beta')
330
+ expect(described_class.recommend(options.merge(context_state: nil))[:action]).to eq('alpha')
331
+ other_state = described_class.state(request: 'scan')
332
+ expect(described_class.recommend(options.merge(state: other_state, context_state: context))[:action]).to eq('alpha')
333
+ end
334
+
335
+ it 'replays contextual values without manufacturing extra contextual samples' do
336
+ described_class.begin_episode(request: 'inspect')
337
+ described_class.observe_step(action: 'file', args: { action: 'read', path: '/not-stored' }, ok: true)
338
+ context = described_class.current_context_state
339
+ described_class.observe_step(action: 'shell', ok: true)
340
+ described_class.finish(score: 1.0)
341
+ table = described_class.load
342
+ table[:q] = {}
343
+ table[:visits] = {}
344
+ described_class.save(table: table)
345
+
346
+ 2.times do
347
+ described_class.warmup!
348
+ expect(described_class.q(state: context, action: 'shell')).to be_positive
349
+ expect(described_class.load[:visits].dig(context.to_sym, :shell)).to eq(1)
350
+ end
351
+ end
352
+
353
+ it 'records only bounded action features while retaining the complete original request in memory' do
354
+ request = "inspect #{'private-request ' * 30}"
355
+ args = { action: 'read', path: '/private/credential-location', token: 'private-token', 'private-key-name' => { nested: 'private-value' } }
356
+ described_class.begin_episode(request: request)
357
+ step = described_class.observe_step(action: 'file', args: args, result_type: :enoent, ok: false)
358
+
359
+ expect(step[:action_context]).to eq(
360
+ operation: 'read',
361
+ arguments: { shape: 'object', size: 'few', features: %w[operation:string other:object other:string path:string] },
362
+ result_type: 'enoent'
363
+ )
364
+ expect(described_class.current_episode[:request]).to eq(request)
365
+ expect(args[:token]).to eq('private-token')
366
+ described_class.finish(score: 0.0)
367
+ persisted = File.read(described_class::TRAJECTORY_FILE) + File.read(described_class::POLICY_FILE)
368
+ expect(persisted).not_to include('private-', '/private/')
369
+ expect(described_class.trajectories.first[:request_family]).to eq('misc')
370
+
371
+ described_class.begin_episode(request: 'inspect')
372
+ unknown = described_class.observe_step(action: 'file', operation: 'private-operation', args: 'private-argument', result_type: 'private-result', ok: true)
373
+ expect(unknown[:action_context]).to include(operation: 'other', result_type: 'other')
374
+ expect(unknown[:action_context][:arguments]).to eq(shape: 'string')
375
+ end
376
+ end
274
377
 
275
378
  it 'terminal reward is judge score scaled by confidence, not step hygiene' do
276
379
  expect(described_class.send(:terminal_reward, score: 1.0, confidence: 0.5)).to be_within(0.01).of(0.5)