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
@@ -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 overlap' do
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
- means = described_class.send(:window_means, window: described_class.send(:load_sentinel)[:window])
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 'solved'
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('solved')
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 'floors verified PASS analytical answers at 0.6' do
413
- src = File.read(PWN::AI::Agent::Reward.method(:judge).source_location.first)
414
- expect(src).to include('[score, 0.6].max')
415
- expect(src).to include('\bPASS\b')
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 'records verifier PASS as success even when the judge scores 0.0' do
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]).to eq(true)
438
- expect(v[:verifier_verdict]).to eq(:pass)
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].each do |m|
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',