pwn 0.5.717 → 0.5.718

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +2 -0
  3. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
  4. data/etc/default_skills/pwn/ai/agent/tools/verify/SKILL.md +45 -0
  5. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/plugins/exploit_db/SKILL.md +7 -0
  7. data/etc/default_skills/pwn/plugins/exploit_db/references/urls.md +4 -0
  8. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/plugins/findings/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +3 -0
  12. data/etc/default_skills/pwn/reports/sarif/SKILL.md +51 -0
  13. data/etc/default_skills/pwn/reports/sarif/references/urls.md +3 -0
  14. data/lib/pwn/ai/agent/dispatch.rb +1 -1
  15. data/lib/pwn/ai/agent/loop.rb +46 -12
  16. data/lib/pwn/ai/agent/mistakes.rb +5 -2
  17. data/lib/pwn/ai/agent/prompt_builder.rb +12 -1
  18. data/lib/pwn/ai/agent/reward.rb +33 -5
  19. data/lib/pwn/ai/agent/swarm.rb +45 -0
  20. data/lib/pwn/ai/agent/tool_guard.rb +38 -6
  21. data/lib/pwn/ai/agent/tools/finding_record.rb +32 -11
  22. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +3 -1
  23. data/lib/pwn/ai/agent/tools/intel_lookup.rb +7 -3
  24. data/lib/pwn/ai/agent/tools/pty_session.rb +43 -0
  25. data/lib/pwn/ai/agent/tools/session_export.rb +2 -2
  26. data/lib/pwn/ai/agent/tools/shell.rb +4 -1
  27. data/lib/pwn/ai/agent/tools/verify.rb +63 -0
  28. data/lib/pwn/memory.rb +56 -6
  29. data/lib/pwn/plugins/aflplusplus.rb +18 -0
  30. data/lib/pwn/plugins/binary_parser.rb +23 -5
  31. data/lib/pwn/plugins/detect_os.rb +1 -11
  32. data/lib/pwn/plugins/exploit_db.rb +155 -27
  33. data/lib/pwn/plugins/exploit_dev.rb +33 -5
  34. data/lib/pwn/plugins/findings.rb +41 -2
  35. data/lib/pwn/plugins/jobs.rb +43 -2
  36. data/lib/pwn/plugins/preflight_checker.rb +11 -1
  37. data/lib/pwn/plugins/process_tube.rb +44 -0
  38. data/lib/pwn/plugins/radare2.rb +9 -1
  39. data/lib/pwn/reports/sarif.rb +60 -0
  40. data/lib/pwn/reports.rb +1 -0
  41. data/lib/pwn/sessions.rb +22 -4
  42. data/lib/pwn/version.rb +1 -1
  43. data/spec/lib/pwn/ai/agent/fixtures/error_class_docker.json +1 -1
  44. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +2 -1
  45. data/spec/lib/pwn/ai/agent/reward_spec.rb +1 -1
  46. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +2 -1
  47. data/spec/lib/pwn/ai/agent/tools/pty_session_spec.rb +1 -0
  48. data/spec/lib/pwn/ai/agent/tools/verify_spec.rb +24 -0
  49. data/spec/lib/pwn/plugins/binary_parser_spec.rb +14 -0
  50. data/spec/lib/pwn/plugins/exploit_db_spec.rb +45 -5
  51. data/spec/lib/pwn/plugins/findings_spec.rb +13 -1
  52. data/spec/lib/pwn/reports/sarif_spec.rb +28 -0
  53. data/third_party/pwn_rdoc.jsonl +28 -1
  54. metadata +9 -2
  55. data/etc/intel/files_exploits.csv +0 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6266cb10d6689157a8e862bb114b93fd248173dc41d54fefc61811eec250b92
4
- data.tar.gz: 23af34c586cbc654a49253d2b6fc83e88da3a8a1bb6178c856d371b64a414767
3
+ metadata.gz: 3c9afec4ab615d169982f573cf42ccd571c2593eec77e5905c131adf0bfb7660
4
+ data.tar.gz: 12d38fd2f938a1d6a68dc0418da107207297d23e0bd4d6a4561e9080f943875b
5
5
  SHA512:
6
- metadata.gz: c133002fa87a475a67777dcad319588a3d5d0626313829a5587ae02b4e87998ba5d13448c2f0712bbc73e7eb5d0f28fcee8916b52cdeb19170adca43d5d70a3c
7
- data.tar.gz: 94790256a870bb3b1b7c4f9e1f8ff26c1ebc0509d0b69738488d9679368ceb8caf62ad9daeb8737f16e9c6a40339850e574a90509855c607030d70132fd58627
6
+ metadata.gz: bfceaf016937af5fe50f5a47b3e4b23bda9c9dc3395c602bd17d36bea8dc9e18eec1b0372d11aea34fe5f856ce323b02368b31094ca28817b5a5beedacae89e7
7
+ data.tar.gz: 3896ef7de35eae9e17c043ea36b1f9258db192f776ee977aa37dde2f5470cd1441109caea605a631bea5ca7a5446aa3a231db779dd83423a003793989c55eca9
@@ -44,6 +44,8 @@ PWN::AI::Agent::Swarm.personas(opts)
44
44
  - `debate`
45
45
  - `broadcast`
46
46
  - `map_targets`
47
+ - `fact_record`
48
+ - `facts_prompt`
47
49
  - `authors`
48
50
  - `help`
49
51
 
@@ -47,6 +47,7 @@ PWN::AI::Agent::ToolGuard.present(opts)
47
47
  - `protect_core_constants`
48
48
  - `coerce_args`
49
49
  - `invalid_payload`
50
+ - `denial`
50
51
  - `host_load`
51
52
  - `deadline_s`
52
53
  - `reset_timeout_budget`
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: pwn-ai-agent-tools-verify
3
+ description: Drive PWN::Ai::Agent::Tools::Verify from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::Ai::Agent::Tools::Verify
10
+ source: pwn/ai/agent/tools/verify.rb
11
+ ---
12
+
13
+ # PWN::Ai::Agent::Tools::Verify
14
+
15
+ Public API for PWN::Ai::Agent::Tools::Verify.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::Ai::Agent::Tools::Verify` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/ai/agent/tools/verify.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::Ai::Agent::Tools::Verify.help
31
+ PWN::Ai::Agent::Tools::Verify.help(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - _(no public class methods parsed)_
37
+
38
+ ## Source
39
+
40
+ `pwn/ai/agent/tools/verify.rb`
41
+
42
+ ## Verification
43
+
44
+ `PWN::Ai::Agent::Tools::Verify.respond_to?(:help)` after the
45
+ module is loaded. Read the source for parameter names.
@@ -37,6 +37,7 @@ PWN::Plugins::AFLplusplus.required_bins(opts)
37
37
  - `fuzz`
38
38
  - `parse_stats`
39
39
  - `crash_triage`
40
+ - `minimize`
40
41
  - `authors`
41
42
  - `help`
42
43
 
@@ -36,9 +36,16 @@ PWN::Plugins::ExploitDB.required_bins(opts)
36
36
  - `required_bins`
37
37
  - `search`
38
38
  - `cve_for_cpe`
39
+ - `gtfobins`
40
+ - `lookup`
41
+ - `sync`
39
42
  - `authors`
40
43
  - `help`
41
44
 
45
+ ## References
46
+
47
+ - `references/urls.md` — URLs from source
48
+
42
49
  ## Source
43
50
 
44
51
  `pwn/plugins/exploit_db.rb`
@@ -0,0 +1,4 @@
1
+ # PWN::Plugins::ExploitDB source links
2
+
3
+ - https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv
4
+ - https://codeload.github.com/GTFOBins/GTFOBins.github.io/tar.gz/master
@@ -50,6 +50,8 @@ PWN::Plugins::ExploitDev.required_bins(opts)
50
50
  - `libc_offsets`
51
51
  - `fmt_writes`
52
52
  - `gadgets`
53
+ - `fmtstr`
54
+ - `io`
53
55
  - `authors`
54
56
  - `help`
55
57
 
@@ -36,6 +36,8 @@ PWN::Plugins::Findings.required_bins(opts)
36
36
  - `required_bins`
37
37
  - `record`
38
38
  - `report`
39
+ - `query`
40
+ - `chain`
39
41
  - `render`
40
42
  - `authors`
41
43
  - `help`
@@ -38,6 +38,8 @@ PWN::Plugins::Jobs.required_bins(opts)
38
38
  - `status`
39
39
  - `tail`
40
40
  - `result`
41
+ - `harvest`
42
+ - `list`
41
43
  - `stop`
42
44
  - `authors`
43
45
  - `help`
@@ -40,6 +40,9 @@ PWN::Plugins::ProcessTube.required_bins(opts)
40
40
  - `recvuntil`
41
41
  - `recvline`
42
42
  - `close`
43
+ - `expect`
44
+ - `stream`
45
+ - `reap_orphans`
43
46
  - `authors`
44
47
  - `help`
45
48
 
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: pwn-reports-sarif
3
+ description: Drive PWN::Reports::SARIF from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::Reports::SARIF
10
+ source: pwn/reports/sarif.rb
11
+ ---
12
+
13
+ # PWN::Reports::SARIF
14
+
15
+ SARIF 2.1.0 writer for finding export.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::Reports::SARIF` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/reports/sarif.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::Reports::SARIF.help
31
+ PWN::Reports::SARIF.generate(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `generate`
37
+ - `authors`
38
+ - `help`
39
+
40
+ ## References
41
+
42
+ - `references/urls.md` — URLs from source
43
+
44
+ ## Source
45
+
46
+ `pwn/reports/sarif.rb`
47
+
48
+ ## Verification
49
+
50
+ `PWN::Reports::SARIF.respond_to?(:generate)` after the
51
+ module is loaded. Read the source for parameter names.
@@ -0,0 +1,3 @@
1
+ # PWN::Reports::SARIF source links
2
+
3
+ - https://json.schemastore.org/sarif-2.1.0.json
@@ -48,7 +48,7 @@ module PWN
48
48
  args = ToolGuard.coerce_args(args: args, required: required) if defined?(ToolGuard)
49
49
  if defined?(ToolGuard) && ToolGuard.respond_to?(:canary_leak?) &&
50
50
  ToolGuard.canary_leak?(text: args.inspect)
51
- return JSON.generate(success: false, error: 'refused: session canary in outbound args')
51
+ return JSON.generate(success: false, error: 'refused: session canary in outbound args', code: 'CANARY_DENY', rule_id: 'canary')
52
52
  end
53
53
  if defined?(ToolGuard) && ToolGuard.respond_to?(:refuse_copied_persist?) &&
54
54
  ToolGuard.refuse_copied_persist?(name: entry.name, args: args)
@@ -660,24 +660,57 @@ module PWN
660
660
  end
661
661
 
662
662
  private_class_method def self.declared_contract_unsatisfied?(opts = {})
663
+ return false if verify_passed?(messages: opts[:messages])
664
+ return false if write_or_read_evidenced?(messages: opts[:messages])
665
+
666
+ completion_unmet(opts).any?
667
+ rescue StandardError
668
+ false
669
+ end
670
+
671
+ private_class_method def self.completion_unmet(opts = {})
672
+ return [] if verify_passed?(messages: opts[:messages])
673
+ return [] if write_or_read_evidenced?(messages: opts[:messages])
674
+
663
675
  contract = declared_contract(request: opts[:request])
664
676
  request = opts[:request].to_s
677
+ unmet = []
665
678
  files = Array(contract[:paths]) + Array(contract[:proofs])
666
- return true if files.any? { |path| deliverable_missing?(path: path) }
667
-
679
+ files.each { |path| unmet << "file_exists:#{path}" if deliverable_missing?(path: path) }
668
680
  trace_files = session_files(messages: opts[:messages])
669
- return true if contract[:issue_work] && Array(contract[:proofs]).empty? && files.empty? && trace_files.empty?
670
-
671
- return true if declared_skills_missing?(skills: contract[:skills], request: request)
672
-
681
+ unmet << 'issue_work_proofs' if contract[:issue_work] && Array(contract[:proofs]).empty? && files.empty? && trace_files.empty?
682
+ unmet << 'skills' if declared_skills_missing?(skills: contract[:skills], request: request)
673
683
  blob = evidence_blob(messages: opts[:messages], files: files + trace_files)
674
684
  asked_hosts = Array(contract[:hosts]).select { |host| request.downcase.include?(host.to_s.downcase) }
675
685
  asked_tech = Array(contract[:techniques]).select { |tech| request.downcase.include?(tech.to_s.downcase) }
676
- return true if evidence_tokens_missing?(tokens: asked_hosts, blob: blob)
677
- return true if evidence_tokens_missing?(tokens: asked_tech, blob: blob)
686
+ unmet << 'hosts' if evidence_tokens_missing?(tokens: asked_hosts, blob: blob)
687
+ unmet << 'techniques' if evidence_tokens_missing?(tokens: asked_tech, blob: blob)
688
+ unmet
689
+ rescue StandardError
690
+ []
691
+ end
692
+
693
+ private_class_method def self.verify_passed?(opts = {})
694
+ Array(opts[:messages]).reverse_each do |msg|
695
+ next unless msg.is_a?(Hash) && msg[:role].to_s == 'tool'
696
+
697
+ raw = unwrap_untrusted(text: msg[:content].to_s)
698
+ parsed = JSON.parse(raw)
699
+ parsed = parsed['result'] || parsed[:result] || parsed
700
+ next unless parsed.is_a?(Hash)
678
701
 
702
+ return true if parsed[:passed] == true || parsed['passed'] == true
703
+ rescue StandardError
704
+ next
705
+ end
679
706
  false
680
- rescue StandardError
707
+ end
708
+
709
+ private_class_method def self.write_or_read_evidenced?(opts = {})
710
+ effects = tool_effects(messages: opts[:messages])
711
+ files = session_files(messages: opts[:messages])
712
+ return true if effects.intersect?(%i[write read]) && files.any?
713
+
681
714
  false
682
715
  end
683
716
 
@@ -2971,11 +3004,12 @@ module PWN
2971
3004
  text: text
2972
3005
  )
2973
3006
  turn_fails['unsatisfied'] += 1
2974
- warn "[pwn-ai/loop] original request not evidenced on iter=#{i}; continuing"
2975
- debug_progress(msg: "bounce unsatisfied snippet=#{debug_snippet(text: text)}")
3007
+ unmet = completion_unmet(request: request, messages: messages)
3008
+ warn "[pwn-ai/loop] original request not evidenced on iter=#{i} unmet=#{unmet.join(',')}; continuing"
3009
+ debug_progress(msg: "bounce unsatisfied unmet=#{unmet.join(',')} snippet=#{debug_snippet(text: text)}")
2976
3010
  messages << {
2977
3011
  role: 'user',
2978
- content: '[pwn-ai] The original request is not evidenced yet. ' \
3012
+ content: "[pwn-ai] The original request is not evidenced yet. unmet=#{unmet.join(',')} " \
2979
3013
  'Keep calling CORE_TOOLS (shell, pwn_eval) until that request is ' \
2980
3014
  'done or a tool returned failure evidence. pwn-ai does not decide ' \
2981
3015
  'authorization. Do not declare completion from a listing or a refusal.'
@@ -112,7 +112,10 @@ module PWN
112
112
 
113
113
  public_class_method def self.error_class(opts = {})
114
114
  e = opts[:error].to_s
115
- return 'auth_denied' if e.match?(/access denied|unauthorized|not authorized|\b401\b|\b403\b|pull access denied/i)
115
+ return 'docker_daemon' if e.match?(/cannot connect to the docker daemon|docker daemon|is the docker daemon running/i)
116
+ return 'docker_registry_auth' if e.match?(/unauthorized: authentication required|pull access denied/i)
117
+ return 'oom' if e.match?(/\boom\b|out of memory|cannot allocate memory/i)
118
+ return 'auth_denied' if e.match?(/access denied|unauthorized|not authorized|\b401\b|\b403\b/i)
116
119
  return 'name_conflict' if e.match?(/already (?:in use|exists)|name conflict|Conflict\.|duplicate/i)
117
120
  return 'parse_error' if e.match?(/parse error|parsererror|template.*error|unexpected token/i)
118
121
  return 'socket_perm' if e.match?(/docker\.sock|unix(?:\s+|:)socket|eperm.*sock|sock.*permission/i)
@@ -873,7 +876,7 @@ module PWN
873
876
  error: 'required - raw error text (will be normalised)'
874
877
  )
875
878
 
876
- # Classify an error into auth_denied/name_conflict/parse_error/socket_perm/missing_path/net_unreach/perm_denied/timeout/other.
879
+ # Classify an error into docker_daemon/docker_registry_auth/oom/auth_denied/name_conflict/parse_error/socket_perm/missing_path/net_unreach/perm_denied/timeout/other.
877
880
  #{self}.error_class(
878
881
  error: 'required - raw error text'
879
882
  )
@@ -201,10 +201,21 @@ module PWN
201
201
  load_line = "load1=#{snap[:load1]} ncpu=#{snap[:ncpu]} mem_avail_mb=#{snap[:mem_avail_mb]}"
202
202
  doc = (PWN::Plugins::PreflightChecker.host_summary if defined?(PWN::Plugins::PreflightChecker))
203
203
  land = (PWN::Plugins::DetectOS.living_off_the_land[:summary] if defined?(PWN::Plugins::DetectOS))
204
+ jobs = if defined?(PWN::Plugins::Jobs)
205
+ run = Array(PWN::Plugins::Jobs.list).select { |j| j[:alive] }
206
+ run.empty? ? '' : " JOBS running=#{run.map { |j| j[:id] }.join(',')}"
207
+ else
208
+ ''
209
+ end
210
+ facts = (PWN::AI::Agent::Swarm.facts_prompt if defined?(PWN::AI::Agent::Swarm))
211
+ rev = [
212
+ (File.mtime(PWN::AI::Agent::Mistakes::MISTAKES_FILE).to_i if defined?(PWN::AI::Agent::Mistakes::MISTAKES_FILE) && File.file?(PWN::AI::Agent::Mistakes::MISTAKES_FILE)),
213
+ (File.mtime(PWN::AI::Agent::Learning::LESSONS_FILE).to_i if defined?(PWN::AI::Agent::Learning::LESSONS_FILE) && File.file?(PWN::AI::Agent::Learning::LESSONS_FILE))
214
+ ].compact.max
204
215
  canary = (Thread.current[:pwn_canary] || ToolGuard.mint_canary if defined?(ToolGuard) && ToolGuard.respond_to?(:mint_canary))
205
216
  "#{load_line} pwn_eval/shell timeout = conservative seconds for this host " \
206
217
  "(defaults eval=#{eval_s || 20}s shell=#{shell_s || 30}s, clamped). " \
207
- "LIVING OFF THE LAND #{land} #{doc} " \
218
+ "LIVING OFF THE LAND #{land} #{doc}#{jobs} #{facts} LESSONS_REV #{rev} " \
208
219
  "CANARY #{canary} never copy this token into tool args. " \
209
220
  'DOMAIN TOOLS for pentest/RE: binary_triage, pty_open, job_run, exploitdev, fuzz_campaign, finding_record, decompile.'
210
221
  rescue StandardError
@@ -188,6 +188,20 @@ module PWN
188
188
  )
189
189
  v[:needs_spot_check] = v[:success] && v[:score].to_f >= 0.85 && (rand < 0.05)
190
190
  v[:engine] = eng
191
+ pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
192
+ v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
193
+ if pass
194
+ v[:score] = [v[:score].to_f, 0.7].max
195
+ v[:score] = [v[:score].to_f, 0.70].min
196
+ end
197
+ v[:score_components] ||= {
198
+ judge: v[:score].to_f,
199
+ overlap: pass ? 0.0 : nil,
200
+ checks: 0.0,
201
+ weights: { overlap: pass ? 0.0 : 0.15 }
202
+ }
203
+ v[:score_components][:weights][:overlap] = 0.0 if pass
204
+ Learning.note_outcome(task: request[0, 80], success: v[:score].to_f >= 0.6, score: v[:score], details: v[:score_components].to_json) if commit && defined?(Learning) && opts[:persist_components]
191
205
  # W3 — write Brier on every judged turn so overconfidence can
192
206
  # throttle max_iters/critic even when plan_first never fired.
193
207
  if commit
@@ -1332,14 +1346,15 @@ module PWN
1332
1346
  req_toks = request.downcase.scan(/[a-z0-9_]{3,}/).uniq
1333
1347
  fin_toks = final.downcase.scan(/[a-z0-9_]{3,}/).uniq
1334
1348
  overlap = req_toks.empty? ? 1.0 : (req_toks & fin_toks).length.to_f / req_toks.length
1349
+ pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
1335
1350
  long_analytical = final.length >= 800
1336
- score = [score, 0.35].min if overlap < 0.08 && req_toks.length >= 4 && score > 0.35 && !long_analytical
1351
+ score = [score, 0.35].min if !pass && overlap < 0.08 && req_toks.length >= 4 && score > 0.35 && !long_analytical
1337
1352
  ev_score = ev ? ev[:score].to_f : 0.0
1338
1353
  bad = trace.count { |t| !semantic_ok(name: 'shell', raw: t.to_s)[:semantic_ok] }
1339
1354
  ratio = trace.empty? ? 0.5 : 1.0 - (bad.to_f / trace.length)
1340
1355
  score = ((score * 0.85) + (ratio * 0.15)).round(3)
1341
- score = [score, 0.6].max if final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
1342
- score = [score, 0.45].min if overlap >= 0.4 && ev_score < 0.55 && !final.match?(/\bPASS\b/)
1356
+ score = [score, 0.7].max if pass
1357
+ score = [score, 0.45].min if !pass && overlap >= 0.4 && ev_score < 0.55
1343
1358
  score = [score, 0.45].min if ratio <= 0.15
1344
1359
  score = [score, 0.70].min
1345
1360
  score = score.round(2).clamp(0.0, 0.70)
@@ -1348,7 +1363,19 @@ module PWN
1348
1363
  else :wrong
1349
1364
  end
1350
1365
  rationale = "heuristic evidence=#{ev ? ev[:score] : '-'} overlap=#{overlap.round(2)} ratio=#{ratio.round(2)}"
1351
- { score: score, verdict: verdict, rationale: rationale, key_step: -1, source: :heuristic }
1366
+ {
1367
+ score: score,
1368
+ verdict: verdict,
1369
+ rationale: rationale,
1370
+ key_step: -1,
1371
+ source: :heuristic,
1372
+ score_components: {
1373
+ judge: score,
1374
+ overlap: pass ? 0.0 : overlap.round(3),
1375
+ checks: 0.0,
1376
+ weights: { overlap: pass ? 0.0 : 0.15 }
1377
+ }
1378
+ }
1352
1379
  end
1353
1380
 
1354
1381
  private_class_method def self.heuristic_prm(opts = {})
@@ -1742,7 +1769,8 @@ module PWN
1742
1769
  commit: 'optional - write score into learning.jsonl / sentinel (default true)',
1743
1770
  critic_pass: 'optional - critic pass value consumed by #judge',
1744
1771
  predicted: 'optional - predicted value consumed by #judge',
1745
- proxy_ok: 'optional - proxy ok value consumed by #judge'
1772
+ proxy_ok: 'optional - proxy ok value consumed by #judge',
1773
+ persist_components: 'optional - write score_components into the learning ledger'
1746
1774
  )
1747
1775
 
1748
1776
  # Run promote to success and return its result
@@ -407,6 +407,37 @@ module PWN
407
407
  end.map(&:value)
408
408
  end
409
409
 
410
+ public_class_method def self.fact_record(opts = {})
411
+ eng = (opts[:engagement_id] || 'default').to_s
412
+ dir = File.join(Dir.home, '.pwn', 'engagements', eng)
413
+ FileUtils.mkdir_p(dir)
414
+ path = File.join(dir, 'facts.jsonl')
415
+ row = {
416
+ kind: (opts[:kind] || 'host').to_s,
417
+ value: opts[:value],
418
+ source_session: opts[:source_session].to_s,
419
+ confidence: (opts[:confidence] || 0.7).to_f,
420
+ at: Time.now.utc.iso8601
421
+ }
422
+ File.open(path, 'a') { |f| f.puts(JSON.generate(row)) }
423
+ row.merge(path: path)
424
+ end
425
+
426
+ public_class_method def self.facts_prompt(opts = {})
427
+ eng = (opts[:engagement_id] || 'default').to_s
428
+ path = File.join(Dir.home, '.pwn', 'engagements', eng, 'facts.jsonl')
429
+ return '' unless File.file?(path)
430
+
431
+ rows = File.readlines(path).filter_map do |ln|
432
+ JSON.parse(ln, symbolize_names: true)
433
+ rescue JSON::ParserError
434
+ nil
435
+ end
436
+ return '' if rows.empty?
437
+
438
+ "FACTS #{rows.map { |r| "#{r[:kind]}=#{r[:value]}" }.join(' ')}"
439
+ end
440
+
410
441
  # Author(s):: 0day Inc. <support@0dayinc.com>
411
442
 
412
443
  public_class_method def self.authors
@@ -490,6 +521,20 @@ module PWN
490
521
  ports: 'optional - comma/space separated ports (defaults to 80,443)'
491
522
  )
492
523
 
524
+ # Append a typed engagement fact (port, cred, host, vuln).
525
+ #{self}.fact_record(
526
+ engagement_id: 'optional - engagement id (defaults to default)',
527
+ kind: 'optional - port|cred|host|vuln (defaults to host)',
528
+ value: 'required - fact value',
529
+ source_session: 'optional - session id that discovered the fact',
530
+ confidence: 'optional - 0.0..1.0 (defaults to 0.7)'
531
+ )
532
+
533
+ # One-line FACTS block for the system prompt.
534
+ #{self}.facts_prompt(
535
+ engagement_id: 'optional - engagement id (defaults to default)'
536
+ )
537
+
493
538
  # Print the AUTHOR(S) string for this module.
494
539
  #{self}.authors
495
540
  "
@@ -4,6 +4,7 @@ require 'digest'
4
4
  require 'json'
5
5
  require 'fileutils'
6
6
  require 'securerandom'
7
+ require 'time'
7
8
 
8
9
  module PWN
9
10
  module AI
@@ -163,16 +164,31 @@ module PWN
163
164
 
164
165
  public_class_method def self.invalid_payload(opts = {})
165
166
  hint = opts[:hint].to_s
167
+ tok = opts[:offending_token].to_s
166
168
  {
167
169
  stdout: '',
168
170
  stderr: hint,
169
171
  exit: 2,
170
172
  error: 'invalid_payload',
173
+ code: (opts[:code] || 'SYNTAX_DENY').to_s,
174
+ rule_id: (opts[:rule_id] || 'payload').to_s,
175
+ offending_token: tok,
176
+ suggestion: opts[:suggestion].to_s,
171
177
  hint: hint,
172
178
  shell: opts[:shell] || shell_name
173
179
  }
174
180
  end
175
181
 
182
+ public_class_method def self.denial(opts = {})
183
+ {
184
+ code: opts[:code].to_s,
185
+ rule_id: opts[:rule_id].to_s,
186
+ token: opts[:token].to_s,
187
+ offending_token: opts[:offending_token] || opts[:token].to_s,
188
+ suggestion: opts[:suggestion].to_s
189
+ }
190
+ end
191
+
176
192
  public_class_method def self.host_load(opts = {})
177
193
  return { ncpu: 1, load1: 0.0, mem_avail_mb: 0 } unless opts.is_a?(Hash)
178
194
 
@@ -407,8 +423,11 @@ module PWN
407
423
  scope = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
408
424
  return nil if scope.nil? || scope.empty?
409
425
 
410
- allow = Array(scope['cidr_allowlist'] || scope[:cidr_allowlist]).map(&:to_s)
411
- domains = Array(scope['domain_allowlist'] || scope[:domain_allowlist]).map(&:to_s)
426
+ expiry = (scope['expiry'] || scope[:expiry]).to_s
427
+ return denial(code: 'SCOPE_DENY', rule_id: 'expiry', token: expiry, suggestion: 'renew ~/.pwn/scope.yaml expiry') unless expiry.empty? || Time.parse(expiry) >= Time.now
428
+
429
+ allow = Array(scope['cidr_allowlist'] || scope[:cidr_allowlist] || scope['cidrs'] || scope[:cidrs]).map(&:to_s)
430
+ domains = Array(scope['domain_allowlist'] || scope[:domain_allowlist] || scope['domains'] || scope[:domains]).map(&:to_s)
412
431
  return nil if allow.empty? && domains.empty?
413
432
 
414
433
  ips = cmd.scan(/\b\d{1,3}(?:\.\d{1,3}){3}\b/)
@@ -418,7 +437,7 @@ module PWN
418
437
  hit = bad_ip || bad_host
419
438
  return nil unless hit
420
439
 
421
- "Refused: #{hit} is outside ~/.pwn/scope.yaml allowlists."
440
+ denial(code: 'SCOPE_DENY', rule_id: 'allowlist', token: hit, suggestion: 'use an in-scope host or CIDR')
422
441
  rescue StandardError
423
442
  nil
424
443
  end
@@ -552,10 +571,23 @@ module PWN
552
571
  required: 'optional - Array required value consumed by #coerce_args'
553
572
  )
554
573
 
555
- # Run invalid payload and return its result
574
+ # Build a machine-readable invalid_payload denial (SYNTAX_DENY by default).
556
575
  #{self}.invalid_payload(
557
- hint: 'optional - hint value consumed by #invalid_payload',
558
- shell: 'optional - shell value consumed by #invalid_payload (defaults to shell_name)'
576
+ hint: 'optional - operator-facing hint string',
577
+ shell: 'optional - shell name (defaults to shell_name)',
578
+ code: 'optional - denial code (defaults to SYNTAX_DENY)',
579
+ rule_id: 'optional - rule identifier (defaults to payload)',
580
+ offending_token: 'optional - exact rejected token span',
581
+ suggestion: 'optional - how to rewrite the payload'
582
+ )
583
+
584
+ # Build a machine-readable guard denial (SCOPE_DENY, CANARY_DENY, ...).
585
+ #{self}.denial(
586
+ code: 'required - denial code such as SCOPE_DENY',
587
+ rule_id: 'optional - rule identifier',
588
+ token: 'optional - out-of-scope host or CIDR',
589
+ offending_token: 'optional - exact rejected token span',
590
+ suggestion: 'optional - how to stay in scope'
559
591
  )
560
592
 
561
593
  # Run host load and return its result
@@ -17,21 +17,42 @@ PWN::AI::Agent::Registry.register(
17
17
  evidence: { type: 'string' },
18
18
  poc: { type: 'string' },
19
19
  poc_artifacts: { type: 'array', items: { type: 'string' } },
20
- session_id: { type: 'string' }
20
+ session_id: { type: 'string' },
21
+ op: { type: 'string' },
22
+ parent_id: { type: 'string' },
23
+ engagement_id: { type: 'string' }
21
24
  },
22
- required: %w[title]
25
+ required: %w[]
23
26
  }
24
27
  },
25
28
  handler: lambda { |args|
26
- PWN::Plugins::Findings.record(
27
- title: args[:title] || args['title'],
28
- severity: args[:severity] || args['severity'],
29
- host: args[:host] || args['host'],
30
- evidence: args[:evidence] || args['evidence'],
31
- poc: args[:poc] || args['poc'],
32
- poc_artifacts: args[:poc_artifacts] || args['poc_artifacts'],
33
- session_id: args[:session_id] || args['session_id']
34
- )
29
+ op = (args[:op] || args['op'] || 'record').to_s
30
+ case op
31
+ when 'query'
32
+ PWN::Plugins::Findings.query(host: args[:host] || args['host'])
33
+ when 'chain'
34
+ PWN::Plugins::Findings.chain(
35
+ parent_id: args[:parent_id] || args['parent_id'],
36
+ title: args[:title] || args['title'],
37
+ severity: args[:severity] || args['severity'],
38
+ poc_artifacts: args[:poc_artifacts] || args['poc_artifacts'],
39
+ poc: args[:poc] || args['poc'],
40
+ session_id: args[:session_id] || args['session_id']
41
+ )
42
+ when 'export'
43
+ PWN::Plugins::Findings.render(report_name: 'findings')
44
+ else
45
+ PWN::Plugins::Findings.record(
46
+ title: args[:title] || args['title'],
47
+ severity: args[:severity] || args['severity'],
48
+ host: args[:host] || args['host'],
49
+ evidence: args[:evidence] || args['evidence'],
50
+ poc: args[:poc] || args['poc'],
51
+ poc_artifacts: args[:poc_artifacts] || args['poc_artifacts'],
52
+ session_id: args[:session_id] || args['session_id'],
53
+ engagement_id: args[:engagement_id] || args['engagement_id']
54
+ )
55
+ end
35
56
  }
36
57
  )
37
58
  PWN::AI::Agent::Registry.register(
@@ -11,7 +11,7 @@ PWN::AI::Agent::Registry.register(
11
11
  parameters: {
12
12
  type: 'object',
13
13
  properties: {
14
- action: { type: 'string', description: 'start|status|stop|triage' },
14
+ action: { type: 'string', description: 'start|status|stop|triage|minimize' },
15
15
  in_dir: { type: 'string' },
16
16
  out_dir: { type: 'string' },
17
17
  target: { type: 'string' },
@@ -37,6 +37,8 @@ PWN::AI::Agent::Registry.register(
37
37
  PWN::Plugins::Jobs.stop(id: args[:id] || args['id'])
38
38
  when 'triage'
39
39
  PWN::Plugins::AFLplusplus.crash_triage(out_dir: args[:out_dir] || args['out_dir'])
40
+ when 'minimize'
41
+ PWN::Plugins::AFLplusplus.minimize(crash: args[:crash] || args['crash'], out: args[:out] || args['out'], target: args[:target] || args['target'])
40
42
  else
41
43
  { error: "unknown action #{act}" }
42
44
  end