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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
4
5
|
|
|
5
6
|
describe PWN::AI::Agent::Registry do
|
|
6
7
|
it 'should display information for authors' do
|
|
@@ -46,6 +47,67 @@ describe PWN::AI::Agent::Registry do
|
|
|
46
47
|
expect(names.length).to eq(described_class::CORE_TOOLS.length)
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
describe 'contextual policy routing' do
|
|
51
|
+
let(:policy) { PWN::AI::Agent::Policy }
|
|
52
|
+
let(:entries) do
|
|
53
|
+
%w[alpha beta].map do |name|
|
|
54
|
+
described_class::Entry.new(name: name, toolset: 'test', schema: { description: 'inspect' })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
around do |example|
|
|
59
|
+
Dir.mktmpdir do |tmp|
|
|
60
|
+
@policy_dir = tmp
|
|
61
|
+
example.run
|
|
62
|
+
ensure
|
|
63
|
+
policy.attach_episode!(episode: nil)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
before do
|
|
68
|
+
stub_const('PWN::AI::Agent::Policy::POLICY_FILE', File.join(@policy_dir, 'policy.json'))
|
|
69
|
+
stub_const('PWN::AI::Agent::Policy::TRAJECTORY_FILE', File.join(@policy_dir, 'policy_traj.jsonl'))
|
|
70
|
+
allow(policy).to receive(:enabled?).and_return(true)
|
|
71
|
+
allow(policy).to receive(:cold?).and_return(true)
|
|
72
|
+
allow(policy).to receive(:warm?).and_return(false)
|
|
73
|
+
allow(PWN::AI::Agent::Metrics).to receive_messages(advantage: 0.0, ucb: 0.0, prm_advantage: 0.0, prm_n: 0, proxy_trust: 1.0)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
operations: [{ operation: 'read' }, { operation: 'search' }],
|
|
78
|
+
argument_features: [{ args: { path: '/not-stored' } }, { args: { query: 'not-stored' } }],
|
|
79
|
+
result_types: [{ result_type: :enoent, ok: false }, { result_type: :timeout, ok: false }]
|
|
80
|
+
}.each do |feature, contexts|
|
|
81
|
+
it "learns opposite next-tool rankings from sanitized previous #{feature}" do
|
|
82
|
+
3.times do
|
|
83
|
+
contexts.each_with_index do |context, index|
|
|
84
|
+
%w[alpha beta].each do |action|
|
|
85
|
+
policy.begin_episode(request: 'inspect')
|
|
86
|
+
policy.observe_step({ action: 'file', operation: 'read', ok: true }.merge(context))
|
|
87
|
+
policy.observe_step(action: action, ok: true)
|
|
88
|
+
score = index.zero? == (action == 'alpha') ? 1.0 : 0.0
|
|
89
|
+
policy.finish(score: score)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
contexts.each_with_index do |context, index|
|
|
95
|
+
policy.begin_episode(request: 'inspect')
|
|
96
|
+
observation = { action: 'file', operation: 'read', ok: true }.merge(context)
|
|
97
|
+
observation[:args] = context[:args].transform_values { 'different-value' } if context[:args]
|
|
98
|
+
policy.observe_step(observation)
|
|
99
|
+
ranked = described_class.rank(query: 'inspect', entries: entries, preference: []).map(&:name)
|
|
100
|
+
expected = index.zero? ? %w[alpha beta] : %w[beta alpha]
|
|
101
|
+
expect(ranked).to eq(expected)
|
|
102
|
+
expect(policy.recommend(actions: %w[alpha beta], epsilon: 0.0)[:action]).to eq(expected.first)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lexical = described_class::Entry.new(name: 'lexical', toolset: 'test', schema: { description: 'inspect priority' })
|
|
106
|
+
expect(described_class.rank(query: 'inspect priority', entries: entries + [lexical], preference: []).first.name).to eq('lexical')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
49
111
|
it 'pins pentest and RE tools when the request is offensive-security work' do
|
|
50
112
|
described_class.discover(force: true)
|
|
51
113
|
defs = described_class.definitions(
|
|
@@ -271,12 +271,14 @@ describe PWN::AI::Agent::Reward do
|
|
|
271
271
|
expect(v[:rationale].to_s).not_to match(/heuristic overlap/)
|
|
272
272
|
end
|
|
273
273
|
|
|
274
|
-
it 'sentinel window_means weights llm_orm above heuristic
|
|
274
|
+
it 'sentinel window_means weights llm_orm above verified heuristic outcomes' do
|
|
275
275
|
tmp = Dir.mktmpdir
|
|
276
276
|
stub_const('PWN::AI::Agent::Reward::SENTINEL_FILE', File.join(tmp, 's.json'))
|
|
277
|
-
20.times { described_class.send(:record_sentinel, proxy: true, judge: 0.9, source: :heuristic) }
|
|
277
|
+
20.times { described_class.send(:record_sentinel, proxy: true, judge: 0.9, source: :heuristic, training_score: 0.9) }
|
|
278
278
|
20.times { described_class.send(:record_sentinel, proxy: true, judge: 0.2, source: :llm_orm) }
|
|
279
|
-
|
|
279
|
+
window = described_class.send(:load_sentinel)[:window]
|
|
280
|
+
expect(window.length).to eq(40)
|
|
281
|
+
means = described_class.send(:window_means, window: window)
|
|
280
282
|
# equal counts: unweighted mean would be 0.55; ORM-weighted mean is closer to 0.2
|
|
281
283
|
expect(means[:judge]).to be < 0.45
|
|
282
284
|
expect(means[:judge]).to be > 0.2
|
|
@@ -329,6 +331,146 @@ describe PWN::AI::Agent::Reward do
|
|
|
329
331
|
end
|
|
330
332
|
end
|
|
331
333
|
|
|
334
|
+
describe 'PWN::AI::Agent::Reward request verification' do
|
|
335
|
+
include_context 'pwn tmp sandbox'
|
|
336
|
+
|
|
337
|
+
it 'invalidates verification when another tool runs after the checks' do
|
|
338
|
+
klass = PWN::AI::Agent::Reward
|
|
339
|
+
session = PWN::Sessions.create(title: 'changed artifact')[:id]
|
|
340
|
+
request = 'write the correct report'
|
|
341
|
+
PWN::Sessions.append(session_id: session, role: 'user', content: request)
|
|
342
|
+
klass.record_verification(request: request, session_id: session,
|
|
343
|
+
checks: [{ criterion: request, passed: true, evidence: 'independent readback' }])
|
|
344
|
+
PWN::Sessions.append(session_id: session, role: 'tool', content: '{"success":true,"effect":"write"}')
|
|
345
|
+
allow(klass).to receive(:llm_judge).and_return(score: 0.2, source: :llm_orm, rationale: 'report changed')
|
|
346
|
+
allow(klass).to receive(:verify_as_reward).and_return(nil)
|
|
347
|
+
v = klass.judge(request: request, session_id: session, final: 'PASS', commit: false)
|
|
348
|
+
expect(v[:verification]).to be_nil
|
|
349
|
+
expect(v[:success]).to be false
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
it 'does not calibrate or persist a heuristic guess as a known outcome' do
|
|
353
|
+
klass = PWN::AI::Agent::Reward
|
|
354
|
+
allow(klass).to receive(:llm_judge).and_return(score: 0.9, source: :heuristic, rationale: 'overlap')
|
|
355
|
+
allow(klass).to receive(:verify_as_reward).and_return(nil)
|
|
356
|
+
expect(PWN::AI::Agent::Curriculum).not_to receive(:calibrate)
|
|
357
|
+
v = klass.judge(request: 'write a report', final: 'PASS report written',
|
|
358
|
+
trace: ['{"success":true}'], persist_components: true)
|
|
359
|
+
row = PWN::AI::Agent::Learning.outcomes.first
|
|
360
|
+
expect(row[:verdict].to_s).to eq('unknown')
|
|
361
|
+
expect(row[:confidence]).to eq(v[:confidence])
|
|
362
|
+
expect(row[:training_score]).to be_nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it 'keeps unknown live judgments out of sentinel calibration and routing distrust' do
|
|
366
|
+
klass = PWN::AI::Agent::Reward
|
|
367
|
+
allow(klass).to receive(:llm_judge).and_return(score: 0.1, source: :heuristic, rationale: 'overlap')
|
|
368
|
+
allow(klass).to receive(:verify_as_reward).and_return(nil)
|
|
369
|
+
allow(PWN::AI::Agent::Curriculum).to receive(:calibrate)
|
|
370
|
+
|
|
371
|
+
klass::SENTINEL_WINDOW.times do
|
|
372
|
+
v = klass.judge(request: 'write a report', final: 'unrelated', proxy_ok: true)
|
|
373
|
+
expect(v).to include(verdict: :unknown, training_score: nil)
|
|
374
|
+
end
|
|
375
|
+
snapshot = klass.sentinel
|
|
376
|
+
|
|
377
|
+
expect(snapshot).to include(samples: 0, status: :insufficient)
|
|
378
|
+
expect(PWN::AI::Agent::Curriculum).not_to have_received(:calibrate)
|
|
379
|
+
expect(klass.proxy_distrust).to eq(0.0)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it 'does not warm the sentinel from unknown canonical or legacy heuristic outcomes' do
|
|
383
|
+
klass = PWN::AI::Agent::Reward
|
|
384
|
+
unknowns = [
|
|
385
|
+
klass.resolve_outcome(outcome: { score: 0.1, source: :heuristic }),
|
|
386
|
+
klass.resolve_outcome(outcome: { score: 0.9, source: :llm_orm }, critic_pass: false),
|
|
387
|
+
klass.resolve_outcome(outcome: { source: :error }),
|
|
388
|
+
{ score: 0.1, success: true, judge_source: 'heuristic' },
|
|
389
|
+
{ score: 0.1, success: true, verdict: 'unknown' }
|
|
390
|
+
]
|
|
391
|
+
File.write(PWN::AI::Agent::Learning::LEARNING_FILE, (unknowns * klass::SENTINEL_WINDOW).map(&:to_json).join("\n"))
|
|
392
|
+
allow(PWN::AI::Agent::Curriculum).to receive(:calibrate)
|
|
393
|
+
|
|
394
|
+
result = klass.warm_sentinel(limit: 200)
|
|
395
|
+
|
|
396
|
+
expect(result).to include(added: 0, samples: 0)
|
|
397
|
+
expect(klass.sentinel).to include(samples: 0, status: :insufficient)
|
|
398
|
+
expect(PWN::AI::Agent::Curriculum).not_to have_received(:calibrate)
|
|
399
|
+
expect(klass.proxy_distrust).to eq(0.0)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'warms known legacy engine scores and canonical verified failures' do
|
|
403
|
+
klass = PWN::AI::Agent::Reward
|
|
404
|
+
failed = klass.resolve_outcome(outcome: {
|
|
405
|
+
score: 0.0, source: :heuristic,
|
|
406
|
+
verification: { checks: [{ criterion: 'report', passed: false, evidence: 'report absent' }] }
|
|
407
|
+
})
|
|
408
|
+
rows = [{ score: 0.7, success: true, judge_source: 'llm_orm' }, failed]
|
|
409
|
+
File.write(PWN::AI::Agent::Learning::LEARNING_FILE, rows.map(&:to_json).join("\n"))
|
|
410
|
+
|
|
411
|
+
expect(klass.warm_sentinel).to include(added: 2, samples: 2)
|
|
412
|
+
expect(klass.send(:load_sentinel)[:window].map { |row| row[:judge] }).to contain_exactly(0.0, 0.7)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
it 'removes unknown persisted sentinel rows and their stale routing distrust' do
|
|
416
|
+
klass = PWN::AI::Agent::Reward
|
|
417
|
+
window = [
|
|
418
|
+
{ judge: 0.7, proxy: 1.0, source: 'llm_orm' },
|
|
419
|
+
{ judge: 0.1, proxy: 1.0, source: 'heuristic' },
|
|
420
|
+
{ judge: 0.9, proxy: 1.0, source: 'llm_orm', training_score: nil, decision_version: 1 },
|
|
421
|
+
{ judge: 0.1, proxy: 1.0, verdict: 'unknown' }
|
|
422
|
+
]
|
|
423
|
+
File.write(klass::SENTINEL_FILE, JSON.generate(window: window, proxy_distrust: 0.85, distrust_at: Time.now.utc.iso8601))
|
|
424
|
+
|
|
425
|
+
expect(klass.sentinel).to include(samples: 1, status: :insufficient)
|
|
426
|
+
expect(klass.send(:load_sentinel)[:window]).to eq([window.first])
|
|
427
|
+
expect(klass.proxy_distrust).to eq(0.0)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
it 'keeps verified heuristic failures in the live sentinel and calibration' do
|
|
431
|
+
klass = PWN::AI::Agent::Reward
|
|
432
|
+
request = 'write the required report'
|
|
433
|
+
session = PWN::Sessions.create(title: request)[:id]
|
|
434
|
+
PWN::Sessions.append(session_id: session, role: 'user', content: request)
|
|
435
|
+
klass.record_verification(request: request, session_id: session,
|
|
436
|
+
checks: [{ criterion: request, passed: false, evidence: 'required report is absent' }])
|
|
437
|
+
allow(klass).to receive(:llm_judge).and_return(score: 0.9, source: :heuristic, rationale: 'overlap')
|
|
438
|
+
allow(klass).to receive(:verify_as_reward).and_return(nil)
|
|
439
|
+
allow(PWN::AI::Agent::Curriculum).to receive(:calibrate)
|
|
440
|
+
|
|
441
|
+
v = klass.judge(request: request, session_id: session, final: 'report written', proxy_ok: true)
|
|
442
|
+
|
|
443
|
+
expect(v).to include(success: false, training_score: 0.2)
|
|
444
|
+
expect(klass.sentinel).to include(samples: 1, status: :insufficient)
|
|
445
|
+
expect(PWN::AI::Agent::Curriculum).to have_received(:calibrate).with(hash_including(actual: 0.2))
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it 'uses only a verifier record bound to the current request and session' do
|
|
449
|
+
klass = PWN::AI::Agent::Reward
|
|
450
|
+
session = PWN::Sessions.create(title: 'verified request')[:id]
|
|
451
|
+
request = 'write the exact report content'
|
|
452
|
+
PWN::Sessions.append(session_id: session, role: 'user', content: request)
|
|
453
|
+
path = File.join(@tmp, 'report.txt')
|
|
454
|
+
File.write(path, 'checked content')
|
|
455
|
+
checks = [{ criterion: request, passed: File.read(path) == 'checked content', evidence: path }]
|
|
456
|
+
klass.record_verification(request: request, session_id: session, checks: checks)
|
|
457
|
+
allow(klass).to receive(:llm_judge).and_return(score: 0.0, source: :heuristic, rationale: 'weak overlap')
|
|
458
|
+
allow(klass).to receive(:verify_as_reward).and_return(nil)
|
|
459
|
+
v = klass.judge(request: request, session_id: session, final: 'Report written.', commit: false)
|
|
460
|
+
expect(v[:success]).to be true
|
|
461
|
+
expect(v[:verdict]).to eq(:solved)
|
|
462
|
+
expect(v[:judge_score]).to eq(0.0)
|
|
463
|
+
expect(v[:confidence]).to eq(1.0)
|
|
464
|
+
expect(v[:training_score]).to be >= 0.6
|
|
465
|
+
expect(v[:verification][:checks]).to eq(checks)
|
|
466
|
+
|
|
467
|
+
PWN::Sessions.append(session_id: session, role: 'user', content: 'another task')
|
|
468
|
+
stale = klass.judge(request: request, session_id: session, final: 'PASS', commit: false)
|
|
469
|
+
expect(stale[:success]).not_to be true
|
|
470
|
+
expect(stale[:verification]).to be_nil
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
332
474
|
describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
333
475
|
it 'does not let a TUI plan_cover_low haircut a high-evidence original-request answer' do
|
|
334
476
|
stub_const('PWN::AI::Agent::Reward::SENTINEL_FILE', File.join(Dir.mktmpdir, 's.json'))
|
|
@@ -364,7 +506,8 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
364
506
|
)
|
|
365
507
|
expect(v[:source].to_s).to eq 'heuristic'
|
|
366
508
|
expect(v[:score]).to be >= 0.6
|
|
367
|
-
expect(v[:verdict].to_s).to eq '
|
|
509
|
+
expect(v[:verdict].to_s).to eq 'unknown'
|
|
510
|
+
expect(v[:training_score]).to be_nil
|
|
368
511
|
args = {
|
|
369
512
|
request: 'what live hosts can you find on this box and write /tmp/pwn-eval-hosts.json',
|
|
370
513
|
final: final,
|
|
@@ -397,7 +540,8 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
397
540
|
expect(a[:score]).to eq(b[:score])
|
|
398
541
|
expect(b[:score]).to eq(c[:score])
|
|
399
542
|
expect(a[:score]).to be >= 0.6
|
|
400
|
-
expect(a[:verdict].to_s).to eq('
|
|
543
|
+
expect(a[:verdict].to_s).to eq('unknown')
|
|
544
|
+
expect(a[:training_score]).to be_nil
|
|
401
545
|
end
|
|
402
546
|
|
|
403
547
|
it 'does not drag a long analytical PASS to partial on zero token overlap' do
|
|
@@ -409,10 +553,17 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
409
553
|
expect(v[:score]).to be > 0.35
|
|
410
554
|
end
|
|
411
555
|
|
|
412
|
-
it '
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
556
|
+
it 'does not turn self-reported PASS or an unrelated successful command into verification' do
|
|
557
|
+
klass = PWN::AI::Agent::Reward
|
|
558
|
+
allow(klass).to receive(:llm_judge).and_return(
|
|
559
|
+
{ score: 0.2, source: :llm_orm, verdict: :wrong, rationale: 'missing requested artifact' }
|
|
560
|
+
)
|
|
561
|
+
allow(klass).to receive(:verify_as_reward).and_return(nil)
|
|
562
|
+
v = klass.judge(request: 'write the requested report', final: 'PASS all done',
|
|
563
|
+
trace: ['{"success":true,"result":{"exit":0}}'], commit: false)
|
|
564
|
+
expect(v[:score]).to eq(0.2)
|
|
565
|
+
expect(v[:success]).to be false
|
|
566
|
+
expect(v[:verifier_verdict]).to be_nil
|
|
416
567
|
end
|
|
417
568
|
|
|
418
569
|
it 'prefers model_routes.judge when selecting a judge model' do
|
|
@@ -423,7 +574,7 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
423
574
|
expect(m).to eq('local-judge')
|
|
424
575
|
end
|
|
425
576
|
|
|
426
|
-
it '
|
|
577
|
+
it 'does not accept an unbound verifier PASS option as completion evidence' do
|
|
427
578
|
allow(PWN::AI::Agent::Reward).to receive(:llm_judge).and_return(
|
|
428
579
|
{ score: 0.0, source: 'heuristic', verdict: :wrong, rationale: 'overlap=0.01', confidence: 0.3 }
|
|
429
580
|
)
|
|
@@ -434,8 +585,20 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
434
585
|
commit: false,
|
|
435
586
|
verifier_verdict: :pass
|
|
436
587
|
)
|
|
437
|
-
expect(v[:success]).
|
|
438
|
-
expect(v[:verifier_verdict]).to
|
|
588
|
+
expect(v[:success]).not_to eq(true)
|
|
589
|
+
expect(v[:verifier_verdict]).to be_nil
|
|
439
590
|
expect(v[:judge_score]).to be < 0.6
|
|
440
591
|
end
|
|
592
|
+
|
|
593
|
+
it 'tags tool-backed heuristics so they are not haircut' do
|
|
594
|
+
allow(PWN::AI::Agent::Reward).to receive(:llm_judge).and_return(nil)
|
|
595
|
+
v = PWN::AI::Agent::Reward.judge(
|
|
596
|
+
request: 'write /tmp/x and verify',
|
|
597
|
+
final: 'PASS file exists',
|
|
598
|
+
trace: ['{"success":true,"result":{"exit":0}}'],
|
|
599
|
+
commit: false
|
|
600
|
+
)
|
|
601
|
+
expect(v[:heuristic_class].to_s).to eq('toolbacked')
|
|
602
|
+
expect(v[:score]).to be >= 0.6
|
|
603
|
+
end
|
|
441
604
|
end
|
|
@@ -15,7 +15,8 @@ describe PWN::AI::Agent::Swarm do
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
it 'exposes the core orchestration API' do
|
|
18
|
-
%i[personas spawn retire create list ask debate broadcast bus_append bus_tail
|
|
18
|
+
%i[personas spawn retire create list ask debate broadcast bus_append bus_tail
|
|
19
|
+
pack_specialist child_inbox child_honesty view_graph claim honesty_unmet].each do |m|
|
|
19
20
|
expect(PWN::AI::Agent::Swarm).to respond_to m
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -54,4 +55,123 @@ describe PWN::AI::Agent::Swarm do
|
|
|
54
55
|
expect(b[:ok]).to eq(false)
|
|
55
56
|
end
|
|
56
57
|
end
|
|
58
|
+
|
|
59
|
+
it 'packs specialists to at most three skills and strips swarm unless orchestrator' do
|
|
60
|
+
packed = described_class.pack_specialist(name: 'xss', skills: %w[xss sqli ssrf auth], toolsets: %w[pwn swarm terminal])
|
|
61
|
+
expect(packed[:skills].length).to eq(3)
|
|
62
|
+
expect(packed[:toolsets]).not_to include('swarm')
|
|
63
|
+
orch = described_class.pack_specialist(name: 'lead', skills: %w[recon], toolsets: %w[swarm pwn], orchestrator: true)
|
|
64
|
+
expect(orch[:toolsets]).to include('swarm')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'spills long bus rows to the artifact store and keeps a short content plus ref' do
|
|
68
|
+
Dir.mktmpdir do |dir|
|
|
69
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
70
|
+
stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', File.join(dir, 'swarm'))
|
|
71
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'art'))
|
|
72
|
+
s = described_class.create(topic: 'bus')
|
|
73
|
+
body = 'P' * 800
|
|
74
|
+
row = described_class.bus_append(swarm_id: s[:swarm_id], from: :red, content: body)
|
|
75
|
+
expect(row[:content].bytesize).to be <= 400
|
|
76
|
+
expect(row[:sha256]).to match(/\A[0-9a-f]{64}\z/)
|
|
77
|
+
expect(row[:ref].to_s).not_to be_empty
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'refuses ask when the work unit is already claimed' do
|
|
82
|
+
Dir.mktmpdir do |dir|
|
|
83
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
84
|
+
stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', File.join(dir, 'swarm'))
|
|
85
|
+
stub_const('PWN::AI::Agent::Swarm::AGENTS_FILE', File.join(dir, 'agents.yml'))
|
|
86
|
+
described_class.spawn(name: 'red', role: 'recon')
|
|
87
|
+
described_class.claim(unit: '10.0.0.5:recon', agent_id: 'other', ttl: 600, engagement_id: 'lab')
|
|
88
|
+
out = described_class.ask(name: 'red', request: 'recon 10.0.0.5', unit: '10.0.0.5:recon', engagement_id: 'lab')
|
|
89
|
+
expect(out[:ok]).to eq(false)
|
|
90
|
+
expect(out[:error]).to eq('claim_held')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'child inbox lists finding ids from Findings, not reply prose' do
|
|
95
|
+
allow(PWN::Plugins::Findings).to receive(:report).and_return(
|
|
96
|
+
[{ id: 'deadbeef', session_id: 'sess-1', title: 'xss' }]
|
|
97
|
+
)
|
|
98
|
+
allow(PWN::Plugins::ArtifactRegistry).to receive(:list).and_return([])
|
|
99
|
+
box = described_class.child_inbox(session_id: 'sess-1', name: 'red')
|
|
100
|
+
expect(box[:finding_ids]).to eq(['deadbeef'])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'flags child honesty gap when pwn toolset filed nothing' do
|
|
104
|
+
allow(described_class).to receive(:child_inbox).and_return(finding_ids: [], artifact_shas: [], coverage: [])
|
|
105
|
+
h = described_class.child_honesty(name: 'xss', toolsets: %w[pwn], session_id: 's')
|
|
106
|
+
expect(h[:gap]).to eq('child_filed_nothing')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'view_graph lists personas and live claims' do
|
|
110
|
+
Dir.mktmpdir do |dir|
|
|
111
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
112
|
+
stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', File.join(dir, 'swarm'))
|
|
113
|
+
s = described_class.create(topic: 'g')
|
|
114
|
+
File.write(File.join(dir, 'swarm', s[:swarm_id], 'personas.json'), JSON.generate('red' => 'sess'))
|
|
115
|
+
described_class.claim(unit: 'h1:recon', agent_id: 'red', ttl: 60, engagement_id: s[:swarm_id])
|
|
116
|
+
g = described_class.view_graph(swarm_id: s[:swarm_id])
|
|
117
|
+
expect(g[:agents].map { |a| a[:name] }).to include('red')
|
|
118
|
+
expect(g[:claims].any? { |c| c[:unit] == 'h1:recon' }).to eq(true)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'stores ephemeral personas under the swarm dir, not the host agents.yml' do
|
|
123
|
+
Dir.mktmpdir do |dir|
|
|
124
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
125
|
+
stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', File.join(dir, 'swarm'))
|
|
126
|
+
stub_const('PWN::AI::Agent::Swarm::AGENTS_FILE', File.join(dir, 'agents.yml'))
|
|
127
|
+
s = described_class.create(topic: 'eph')
|
|
128
|
+
described_class.spawn(name: 'xss', role: 'xss only', swarm_id: s[:swarm_id], ephemeral: true)
|
|
129
|
+
expect(File).not_to exist(File.join(dir, 'agents.yml'))
|
|
130
|
+
expect(described_class.personas(swarm_id: s[:swarm_id]).keys.map(&:to_s)).to include('xss')
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'broadcast fans out with threads' do
|
|
135
|
+
src = File.read(described_class.method(:broadcast).source_location.first)
|
|
136
|
+
expect(src).to match(/Thread\.new/)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'ask passes nested: true so nested Loop.run does not write the RN footer' do
|
|
140
|
+
src = File.read(described_class.method(:ask).source_location.first)
|
|
141
|
+
expect(src).to match(/nested:\s*true/)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'migrate_personas empties stock escalator tools and adds pwn to scribe' do
|
|
145
|
+
Dir.mktmpdir do |dir|
|
|
146
|
+
path = File.join(dir, 'agents.yml')
|
|
147
|
+
File.write(path, <<~YML)
|
|
148
|
+
---
|
|
149
|
+
red:
|
|
150
|
+
role: keep
|
|
151
|
+
engine: grok
|
|
152
|
+
toolsets: [terminal, pwn]
|
|
153
|
+
max_iters: 25
|
|
154
|
+
scribe:
|
|
155
|
+
role: report
|
|
156
|
+
engine: grok
|
|
157
|
+
toolsets: [memory, skills, learning, sessions]
|
|
158
|
+
max_iters: 10
|
|
159
|
+
escalator:
|
|
160
|
+
role: hint
|
|
161
|
+
engine: grok
|
|
162
|
+
toolsets: [terminal, pwn, memory]
|
|
163
|
+
max_iters: 8
|
|
164
|
+
YML
|
|
165
|
+
out = described_class.migrate_personas(path: path)
|
|
166
|
+
expect(out[:changed]).to eq(true)
|
|
167
|
+
expect(out[:patched]).to include('escalator', 'scribe')
|
|
168
|
+
again = described_class.migrate_personas(path: path)
|
|
169
|
+
expect(again[:changed]).to eq(false)
|
|
170
|
+
loaded = described_class.send(:load_personas_file, path: path)
|
|
171
|
+
expect(loaded[:escalator][:toolsets]).to eq([])
|
|
172
|
+
expect(loaded[:scribe][:toolsets]).to include('pwn')
|
|
173
|
+
expect(loaded[:red][:engine]).to eq(:grok)
|
|
174
|
+
expect(loaded[:red][:toolsets]).to eq(%w[terminal pwn])
|
|
175
|
+
end
|
|
176
|
+
end
|
|
57
177
|
end
|
|
@@ -67,6 +67,12 @@ describe PWN::AI::Agent::ToolGuard do
|
|
|
67
67
|
expect(out[:exit]).to eq(2)
|
|
68
68
|
expect(out[:error]).to eq('invalid_payload')
|
|
69
69
|
expect(out[:stderr]).to include('missing required command')
|
|
70
|
+
expect(out[:max_payload_bytes]).to eq(described_class::MAX_PAYLOAD_BYTES)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'names the byte range of a placeholder token' do
|
|
74
|
+
out = described_class.invalid_payload(hint: 'ellipsis', offending_token: '...', text: 'foo ... bar')
|
|
75
|
+
expect(out[:byte_range]).to eq([4, 7])
|
|
70
76
|
end
|
|
71
77
|
end
|
|
72
78
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'PWN::AI::Agent::Tools capabilities' do
|
|
6
|
+
before(:all) do
|
|
7
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
8
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/capabilities.rb'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'registers the capabilities tool' do
|
|
12
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'capabilities')).not_to be_nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'PWN::AI::Agent::Tools context' do
|
|
6
|
+
before(:all) do
|
|
7
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
8
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/context.rb'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'registers the context_attach tool' do
|
|
12
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'context_attach')).not_to be_nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -12,5 +12,7 @@ describe 'PWN::AI::Agent::Tools job_run' do
|
|
|
12
12
|
expect(PWN::AI::Agent::Registry.lookup(name: 'job_run')).not_to be_nil
|
|
13
13
|
expect(PWN::AI::Agent::Registry.lookup(name: 'job_status')).not_to be_nil
|
|
14
14
|
expect(PWN::AI::Agent::Registry.lookup(name: 'job_result')).not_to be_nil
|
|
15
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'job_tail')).not_to be_nil
|
|
16
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'job_kill')).not_to be_nil
|
|
15
17
|
end
|
|
16
18
|
end
|
|
@@ -8,6 +8,31 @@ describe 'PWN::AI::Agent::Tools learning' do
|
|
|
8
8
|
expect(PWN::AI::Agent::Registry.lookup(name: 'learning_note_outcome')).not_to be_nil
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
[true, false].each do |reported_success|
|
|
12
|
+
it "stores a model claim of success=#{reported_success} as an unverified note" do
|
|
13
|
+
Dir.mktmpdir do |dir|
|
|
14
|
+
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(dir, 'learning.jsonl'))
|
|
15
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
16
|
+
tool = PWN::AI::Agent::Registry.lookup(name: 'learning_note_outcome')
|
|
17
|
+
expect(PWN::AI::Agent::Learning).not_to receive(:promote_process_lesson)
|
|
18
|
+
expect(PWN::AI::Agent::Curriculum).not_to receive(:calibrate)
|
|
19
|
+
|
|
20
|
+
entry = tool[:handler].call(task: 'Run the regression suite', success: reported_success,
|
|
21
|
+
details: 'Claimed test result without an independent verifier.', tags: ['tests'])
|
|
22
|
+
|
|
23
|
+
expect(entry).to include(success: nil, status: 'unverified', verdict: :unknown,
|
|
24
|
+
score: nil, training_score: nil, judge_source: 'self_report')
|
|
25
|
+
expect(entry[:details]).to include("reported_success=#{reported_success}", 'Claimed test result without an independent verifier.')
|
|
26
|
+
expect(entry[:tags]).to include('tests', 'unknown')
|
|
27
|
+
saved = PWN::AI::Agent::Learning.outcomes
|
|
28
|
+
expect(saved.length).to eq(1)
|
|
29
|
+
expect(saved.first[:details]).to eq(entry[:details])
|
|
30
|
+
expect(PWN::AI::Agent::Learning.outcomes(success: true)).to be_empty
|
|
31
|
+
expect(PWN::AI::Agent::Learning.outcomes(success: false)).to be_empty
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
11
36
|
it 'registers the learning_reflect tool' do
|
|
12
37
|
PWN::AI::Agent::Registry.discover(force: true)
|
|
13
38
|
expect(PWN::AI::Agent::Registry.lookup(name: 'learning_reflect')).not_to be_nil
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
describe PWN::AI::Context do
|
|
7
|
+
it 'should display information for authors' do
|
|
8
|
+
expect(described_class).to respond_to :authors
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'should display information for existing help method' do
|
|
12
|
+
expect(described_class).to respond_to :help
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'attaches a file with sha256 and chunks' do
|
|
16
|
+
Dir.mktmpdir do |dir|
|
|
17
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
18
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
19
|
+
path = File.join(dir, 'note.md')
|
|
20
|
+
File.write(path, 'hello world ' * 20)
|
|
21
|
+
row = described_class.attach_file(path: path, session_id: 's1')
|
|
22
|
+
expect(row[:sha256]).to match(/\A[0-9a-f]{64}\z/)
|
|
23
|
+
expect(row[:chunks]).not_to be_empty
|
|
24
|
+
expect(File.file?(row[:path])).to be true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'attaches a hexdump slice' do
|
|
29
|
+
Dir.mktmpdir do |dir|
|
|
30
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
31
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
32
|
+
path = File.join(dir, 'blob.bin')
|
|
33
|
+
File.binwrite(path, "\x7fELF#{0.chr * 64}")
|
|
34
|
+
row = described_class.attach_hexdump(path: path, offset: 0, length: 16)
|
|
35
|
+
expect(row[:kind]).to eq('hexdump')
|
|
36
|
+
expect(row[:sha256]).not_to be_empty
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'attaches an HTTP transcript string' do
|
|
41
|
+
Dir.mktmpdir do |dir|
|
|
42
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
43
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
44
|
+
row = described_class.attach_http_transcript(har_or_raw: '{"log":{"entries":[]}}')
|
|
45
|
+
expect(row[:kind]).to eq('http_transcript')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -43,6 +43,33 @@ describe PWN::AI::HttpRetry do
|
|
|
43
43
|
PWN::Plugins::Log.stop_debug if defined?(PWN::Plugins::Log)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
it 'honors Retry-After seconds and does not use sub-second fallback' do
|
|
47
|
+
resp = instance_double('RestClient::Response', headers: { retry_after: '45' })
|
|
48
|
+
expect(described_class.retry_after_s(response: resp, retry_count: 1)).to eq(45.0)
|
|
49
|
+
expect(described_class.retry_after_s(retry_count: 1)).to be >= 2.0
|
|
50
|
+
expect(described_class.retry_after_s(retry_count: 4)).to be >= 8.0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'treats insufficient_quota 429 bodies as non-retryable' do
|
|
54
|
+
resp = instance_double('RestClient::Response', body: '{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}')
|
|
55
|
+
err = instance_double('RestClient::TooManyRequests', message: '429 Too Many Requests', response: resp)
|
|
56
|
+
expect(described_class.quota_exhausted?(error: err)).to eq(true)
|
|
57
|
+
expect(described_class.quota_exhausted?(error: '429 rate_limit_exceeded')).to eq(false)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'builds an operator billing line from credit_balance_exhausted 429 bodies' do
|
|
61
|
+
resp = instance_double(
|
|
62
|
+
'RestClient::Response',
|
|
63
|
+
body: '{"error":{"message":"You have no credits remaining. Add credits to continue using the API at https://platform.openai.com/settings/organization/billing/.","type":"insufficient_quota","code":"credit_balance_exhausted"}}'
|
|
64
|
+
)
|
|
65
|
+
err = instance_double('RestClient::TooManyRequests', message: '429 Too Many Requests', response: resp)
|
|
66
|
+
expect(described_class.quota_exhausted?(error: err)).to eq(true)
|
|
67
|
+
msg = described_class.quota_message(error: err)
|
|
68
|
+
expect(msg).to include('no credits')
|
|
69
|
+
expect(msg).to include('platform.openai.com/settings/organization/billing')
|
|
70
|
+
expect(msg).to include('ChatGPT')
|
|
71
|
+
end
|
|
72
|
+
|
|
46
73
|
{
|
|
47
74
|
'PWN::AI::Grok' => '/opt/pwn/lib/pwn/ai/grok.rb',
|
|
48
75
|
'PWN::AI::OpenAI' => '/opt/pwn/lib/pwn/ai/open_ai.rb',
|