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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/bin/pwn_setup +5 -5
- data/documentation/AI-Integration.md +34 -1
- data/documentation/Policy-Benchmark.md +151 -0
- data/documentation/Reinforcement-Learning.md +27 -1
- data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
- data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
- data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
- data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
- data/lib/pwn/ai/agent/curriculum.rb +37 -45
- data/lib/pwn/ai/agent/dispatch.rb +9 -0
- data/lib/pwn/ai/agent/engagement.rb +59 -0
- data/lib/pwn/ai/agent/learning.rb +111 -44
- data/lib/pwn/ai/agent/loop.rb +50 -8
- data/lib/pwn/ai/agent/metrics.rb +70 -2
- data/lib/pwn/ai/agent/mistakes.rb +67 -1
- data/lib/pwn/ai/agent/policy.rb +142 -16
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/reward.rb +153 -53
- data/lib/pwn/ai/agent/swarm.rb +235 -35
- data/lib/pwn/ai/agent/tool_guard.rb +13 -1
- data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
- data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
- data/lib/pwn/ai/agent/tools/context.rb +38 -0
- data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
- data/lib/pwn/ai/agent/tools/learning.rb +5 -6
- data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
- data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
- data/lib/pwn/ai/agent/tools/shell.rb +10 -1
- data/lib/pwn/ai/agent/tools/skills.rb +30 -0
- data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
- data/lib/pwn/ai/context.rb +193 -0
- data/lib/pwn/ai/http_retry.rb +53 -7
- data/lib/pwn/ai/open_ai.rb +302 -45
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/migrate.rb +10 -1
- data/lib/pwn/plugins/artifact_registry.rb +40 -17
- data/lib/pwn/plugins/binary_parser.rb +4 -1
- data/lib/pwn/plugins/exploit_dev.rb +15 -0
- data/lib/pwn/plugins/findings.rb +48 -8
- data/lib/pwn/plugins/gdb.rb +17 -0
- data/lib/pwn/plugins/gdbmi.rb +128 -0
- data/lib/pwn/plugins/ghidra_headless.rb +104 -0
- data/lib/pwn/plugins/jobs.rb +72 -0
- data/lib/pwn/plugins/packet.rb +51 -0
- data/lib/pwn/plugins/preflight_checker.rb +29 -0
- data/lib/pwn/plugins/process_tube.rb +24 -7
- data/lib/pwn/plugins/radare2.rb +14 -2
- data/lib/pwn/plugins/repl.rb +6 -1
- data/lib/pwn/plugins/transparent_browser.rb +64 -0
- data/lib/pwn/plugins.rb +2 -0
- data/lib/pwn/reports/engagement.rb +19 -0
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/scripts/benchmark_policy.rb +276 -0
- data/spec/documentation/installation_md_spec.rb +18 -4
- data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
- data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
- data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
- data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
- data/spec/lib/pwn/ai/context_spec.rb +48 -0
- data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
- data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
- data/spec/lib/pwn/migrate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
- data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
- data/third_party/pwn_rdoc.jsonl +86 -2
- 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
|
-
#
|
|
336
|
-
expect(stats[:judge_mean]).to
|
|
337
|
-
expect(stats[:
|
|
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)
|