pwn 0.5.618 → 0.5.621

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/README.md +14 -6
  4. data/bin/pwn_gqrx_scanner +25 -5
  5. data/documentation/Agent-Tool-Registry.md +4 -4
  6. data/documentation/Cron.md +13 -0
  7. data/documentation/Diagrams.md +6 -2
  8. data/documentation/Extrospection.md +96 -11
  9. data/documentation/Home.md +5 -4
  10. data/documentation/How-PWN-Works.md +7 -4
  11. data/documentation/Mistakes.md +97 -0
  12. data/documentation/Persistence.md +4 -1
  13. data/documentation/SDR.md +23 -5
  14. data/documentation/Skills-Memory-Learning.md +16 -5
  15. data/documentation/Transparent-Browser.md +18 -1
  16. data/documentation/diagrams/agent-tool-registry.svg +103 -100
  17. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  18. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  19. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  20. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  21. data/documentation/diagrams/cron-scheduling.svg +23 -23
  22. data/documentation/diagrams/dot/agent-tool-registry.dot +3 -3
  23. data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -10
  24. data/documentation/diagrams/dot/memory-skills-detailed.dot +16 -5
  25. data/documentation/diagrams/dot/mistakes-negative-feedback.dot +93 -0
  26. data/documentation/diagrams/dot/overall-pwn-architecture.dot +7 -5
  27. data/documentation/diagrams/dot/persistence-filesystem.dot +4 -2
  28. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +25 -7
  29. data/documentation/diagrams/dot/sdr-radio-flow.dot +13 -11
  30. data/documentation/diagrams/driver-framework.svg +13 -13
  31. data/documentation/diagrams/extrospection-world-awareness.svg +241 -93
  32. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  33. data/documentation/diagrams/hardware-hacking.svg +18 -18
  34. data/documentation/diagrams/history-to-drivers.svg +18 -18
  35. data/documentation/diagrams/memory-skills-detailed.svg +169 -97
  36. data/documentation/diagrams/mistakes-negative-feedback.svg +266 -0
  37. data/documentation/diagrams/network-infra-testing.svg +27 -27
  38. data/documentation/diagrams/overall-pwn-architecture.svg +184 -173
  39. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  40. data/documentation/diagrams/persistence-filesystem.svg +111 -87
  41. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  42. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +263 -149
  43. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  44. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  45. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  46. data/documentation/diagrams/sdr-radio-flow.svg +76 -56
  47. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  48. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  49. data/documentation/diagrams/web-application-testing.svg +26 -26
  50. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  51. data/documentation/pwn-ai-Agent.md +28 -15
  52. data/lib/pwn/ai/agent/extrospection.rb +494 -6
  53. data/lib/pwn/ai/agent/learning.rb +67 -7
  54. data/lib/pwn/ai/agent/loop.rb +72 -6
  55. data/lib/pwn/ai/agent/mistakes.rb +369 -0
  56. data/lib/pwn/ai/agent/prompt_builder.rb +10 -1
  57. data/lib/pwn/ai/agent/tools/extrospection.rb +88 -3
  58. data/lib/pwn/ai/agent/tools/mistakes.rb +132 -0
  59. data/lib/pwn/ai/agent.rb +1 -0
  60. data/lib/pwn/plugins/jira_data_center.rb +2 -2
  61. data/lib/pwn/sdr/decoder/adsb.rb +82 -0
  62. data/lib/pwn/sdr/decoder/apt.rb +136 -0
  63. data/lib/pwn/sdr/decoder/base.rb +258 -171
  64. data/lib/pwn/sdr/decoder/bluetooth.rb +71 -0
  65. data/lib/pwn/sdr/decoder/dect.rb +70 -0
  66. data/lib/pwn/sdr/decoder/dsp.rb +396 -0
  67. data/lib/pwn/sdr/decoder/flex.rb +177 -210
  68. data/lib/pwn/sdr/decoder/gps.rb +74 -0
  69. data/lib/pwn/sdr/decoder/gsm.rb +31 -62
  70. data/lib/pwn/sdr/decoder/iridium.rb +66 -0
  71. data/lib/pwn/sdr/decoder/lora.rb +80 -0
  72. data/lib/pwn/sdr/decoder/lte.rb +67 -0
  73. data/lib/pwn/sdr/decoder/morse.rb +138 -0
  74. data/lib/pwn/sdr/decoder/p25.rb +74 -0
  75. data/lib/pwn/sdr/decoder/pager.rb +61 -0
  76. data/lib/pwn/sdr/decoder/pocsag.rb +176 -54
  77. data/lib/pwn/sdr/decoder/rfid.rb +79 -0
  78. data/lib/pwn/sdr/decoder/rtl433.rb +86 -0
  79. data/lib/pwn/sdr/decoder/rtty.rb +139 -0
  80. data/lib/pwn/sdr/decoder/wifi.rb +77 -0
  81. data/lib/pwn/sdr/decoder/zigbee.rb +74 -0
  82. data/lib/pwn/sdr/decoder.rb +75 -7
  83. data/lib/pwn/sdr/frequency_allocation.rb +82 -41
  84. data/lib/pwn/sdr/gqrx.rb +186 -95
  85. data/lib/pwn/version.rb +1 -1
  86. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +36 -0
  87. data/spec/lib/pwn/ai/agent/tools/mistakes_spec.rb +12 -0
  88. data/spec/lib/pwn/sdr/decoder/adsb_spec.rb +15 -0
  89. data/spec/lib/pwn/sdr/decoder/apt_spec.rb +15 -0
  90. data/spec/lib/pwn/sdr/decoder/bluetooth_spec.rb +15 -0
  91. data/spec/lib/pwn/sdr/decoder/dect_spec.rb +15 -0
  92. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
  93. data/spec/lib/pwn/sdr/decoder/gps_spec.rb +15 -0
  94. data/spec/lib/pwn/sdr/decoder/iridium_spec.rb +15 -0
  95. data/spec/lib/pwn/sdr/decoder/lora_spec.rb +15 -0
  96. data/spec/lib/pwn/sdr/decoder/lte_spec.rb +15 -0
  97. data/spec/lib/pwn/sdr/decoder/morse_spec.rb +15 -0
  98. data/spec/lib/pwn/sdr/decoder/p25_spec.rb +15 -0
  99. data/spec/lib/pwn/sdr/decoder/pager_spec.rb +15 -0
  100. data/spec/lib/pwn/sdr/decoder/rfid_spec.rb +15 -0
  101. data/spec/lib/pwn/sdr/decoder/rtl433_spec.rb +15 -0
  102. data/spec/lib/pwn/sdr/decoder/rtty_spec.rb +15 -0
  103. data/spec/lib/pwn/sdr/decoder/wifi_spec.rb +15 -0
  104. data/spec/lib/pwn/sdr/decoder/zigbee_spec.rb +15 -0
  105. data/third_party/pwn_rdoc.jsonl +115 -5
  106. metadata +46 -6
  107. data/README.md.bak +0 -200
@@ -2,15 +2,16 @@
2
2
 
3
3
  `pwn-ai` is a natural-language front end to everything in `PWN::`. You describe
4
4
  the goal; the agent plans a sequence of tool calls (`pwn_eval`, `shell`,
5
- `memory_*`, `skill_*`, `extro_*`, `agent_*`, …), executes them against the live
6
- process, observes the results, and loops until it can give you a final answer.
5
+ `memory_*`, `skill_*`, `mistakes_*`, `extro_*`, `agent_*`, …), executes them
6
+ against the live process, observes the results, and loops until it can give you
7
+ a final answer — **learning from every failure so it doesn't repeat it**.
7
8
 
8
9
  ## Two ways to run it
9
10
 
10
11
  ```text
11
12
  # 1. Interactive TUI (inside the pwn REPL)
12
- pwn[v0.5.616]:001 >>> pwn-ai
13
- ✨ pwn-ai · anthropic · session 20260707_220802_3f39791f
13
+ pwn[v0.5.618]:001 >>> pwn-ai
14
+ ✨ pwn-ai · anthropic · session 20260707_225041_d7f2f3bb
14
15
  > Use NmapIt to sweep 10.0.0.0/24, then TransparentBrowser via Burp on any
15
16
  host with 443 open, active-scan, and give me a Reports::SAST summary.
16
17
  ```
@@ -23,12 +24,21 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
23
24
  ## Anatomy of a turn
24
25
 
25
26
  1. **PromptBuilder** assembles the system prompt: your request + MEMORY block +
26
- SKILLS list + LEARNING stats + EXTROSPECTION block.
27
- 2. **Loop** sends it to the active `PWN::AI::<Engine>` client.
28
- 3. Provider replies with `tool_calls` **Dispatch** executes each one via the
29
- [Registry](Agent-Tool-Registry.md), **Metrics** records duration/success.
30
- 4. Results are appended to the message list; go to 2.
31
- 5. When the reply has *no* tool_calls it's the **final answer**
27
+ SKILLS list + LEARNING stats + **KNOWN MISTAKES / KNOWN FIXES** +
28
+ TOOL EFFECTIVENESS + EXTROSPECTION block.
29
+ 2. **Loop** checks the incoming message against `Mistakes::CORRECTION_RX` if
30
+ it reads like *"no, that's wrong"* the previous outcome is flipped to
31
+ `success:false` and fingerprinted.
32
+ 3. Loop sends the prompt to the active `PWN::AI::<Engine>` client.
33
+ 4. Provider replies with `tool_calls` → **Dispatch** executes each one via the
34
+ [Registry](Agent-Tool-Registry.md); **Metrics** records duration/success.
35
+ Any *failure* is fingerprinted into **Mistakes** (`count++`, cross-session)
36
+ and the tool result gets an inline `correction_hint` (`seen N×, sig=…,
37
+ KNOWN FIX: …`) so the very next iteration self-corrects. If the persistent
38
+ count ≥ 3, `guard_repeated_failure` interrupts with an explicit
39
+ *change-approach* instruction.
40
+ 5. Results are appended to the message list; go to 3.
41
+ 6. When the reply has *no* tool_calls it's the **final answer** →
32
42
  `Learning.auto_reflect` and `Extrospection.auto_extrospect` fire (if
33
43
  enabled), transcript is flushed to `~/.pwn/sessions/`.
34
44
 
@@ -36,7 +46,7 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
36
46
 
37
47
  ## What the agent can call
38
48
 
39
- 10 toolsets · 45+ tools — full table at
49
+ 10 toolsets · 54 tools — full table at
40
50
  [Agent Tool Registry](Agent-Tool-Registry.md).
41
51
 
42
52
  The two that matter most:
@@ -46,9 +56,9 @@ The two that matter most:
46
56
  | `pwn_eval` | **Any** Ruby in-process — the whole `PWN::` namespace, `require`, monkey-patch, everything |
47
57
  | `shell` | **Any** OS command on the host |
48
58
 
49
- Everything else (memory, skills, learning, extrospection, cron, swarm,
50
- sessions, metrics) is a convenience wrapper the model can discover from the
51
- schema alone.
59
+ Everything else (memory, skills, learning, **mistakes**, extrospection, cron,
60
+ swarm, sessions, metrics) is a convenience wrapper the model can discover from
61
+ the schema alone.
52
62
 
53
63
  ## Delegating to other agents
54
64
 
@@ -64,9 +74,12 @@ full `Loop.run` under a persona overlay) that share a JSONL bus. See
64
74
  - Disable `auto_reflect` during noisy fuzz loops
65
75
  (`learning_auto_reflect_toggle(enabled: false)`), re-enable for the summary
66
76
  turn.
77
+ - Run `mistakes_list` before retrying something that failed last session — the
78
+ fix may already be recorded.
67
79
 
68
80
  **See also:** [AI Integration](AI-Integration.md) ·
69
81
  [Skills, Memory & Learning](Skills-Memory-Learning.md) ·
70
- [Extrospection](Extrospection.md) · [Swarm](Swarm.md) · [Cron](Cron.md)
82
+ [Mistakes](Mistakes.md) · [Extrospection](Extrospection.md) ·
83
+ [Swarm](Swarm.md) · [Cron](Cron.md)
71
84
 
72
85
  [← Home](Home.md)
@@ -9,6 +9,7 @@ require 'open3'
9
9
  require 'etc'
10
10
  require 'uri'
11
11
  require 'shellwords'
12
+ require 'timeout'
12
13
 
13
14
  module PWN
14
15
  module AI
@@ -41,7 +42,14 @@ module PWN
41
42
  module Extrospection
42
43
  EXTRO_FILE = File.join(Dir.home, '.pwn', 'extrospection.json')
43
44
  MAX_OBSERVATIONS = 500
44
- PROBE_BINS = %w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker].freeze
45
+ RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
46
+ PROBE_BINS = (%w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker] + RF_BINS).freeze
47
+ WEB_SHOT_DIR = File.join(Dir.home, '.pwn', 'extrospection', 'web')
48
+ DEFAULT_WEB_ANCHORS = %w[
49
+ https://services.nvd.nist.gov/rest/json/cves/2.0
50
+ https://www.exploit-db.com/
51
+ https://raw.githubusercontent.com/0dayinc/pwn/master/lib/pwn/version.rb
52
+ ].freeze
45
53
 
46
54
  # Supported Method Parameters::
47
55
  # store = PWN::AI::Agent::Extrospection.load
@@ -71,7 +79,7 @@ module PWN
71
79
  # Supported Method Parameters::
72
80
  # snap = PWN::AI::Agent::Extrospection.snapshot(
73
81
  # persist: 'optional - Boolean, write snapshot to disk & rotate previous (default true)',
74
- # sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env] (default all)'
82
+ # sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf, :web] (default all except :web)'
75
83
  # )
76
84
  #
77
85
  # Captures a fingerprint of the OUTSIDE world. When persist:true the
@@ -84,7 +92,7 @@ module PWN
84
92
  true
85
93
  end
86
94
  sections = Array(opts[:sections]).map(&:to_sym)
87
- sections = %i[host net toolchain repo env] if sections.empty?
95
+ sections = %i[host net toolchain repo env rf] if sections.empty?
88
96
 
89
97
  snap = {}
90
98
  snap[:host] = probe_host if sections.include?(:host)
@@ -92,6 +100,8 @@ module PWN
92
100
  snap[:toolchain] = probe_toolchain if sections.include?(:toolchain)
93
101
  snap[:repo] = probe_repo if sections.include?(:repo)
94
102
  snap[:env] = probe_env if sections.include?(:env)
103
+ snap[:rf] = probe_rf if sections.include?(:rf)
104
+ snap[:web] = probe_web if sections.include?(:web)
95
105
  snap[:captured_at] = Time.now.utc.iso8601
96
106
  snap[:fingerprint] = Digest::SHA256.hexdigest(JSON.generate(snap.except(:captured_at)))[0, 16]
97
107
 
@@ -110,7 +120,7 @@ module PWN
110
120
  # obs = PWN::AI::Agent::Extrospection.observe(
111
121
  # source: 'required - where the observation came from (nmap, shodan, burp, cve, human, ...)',
112
122
  # data: 'required - the observation payload (String or Hash)',
113
- # category: 'optional - :recon, :vuln, :intel, :target, :network, :misc (default :misc)',
123
+ # category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :web, :misc (default :misc)',
114
124
  # target: 'optional - host/ip/url/asset the observation is about',
115
125
  # tags: 'optional - Array of String labels',
116
126
  # ttl: 'optional - seconds until this observation is considered stale (default nil = forever)'
@@ -236,6 +246,189 @@ module PWN
236
246
  { query: query, feeds: feeds, results: results, total: results.values.flatten.compact.length }
237
247
  end
238
248
 
249
+ # Supported Method Parameters::
250
+ # verdict = PWN::AI::Agent::Extrospection.verify(
251
+ # claim: 'required - factual claim to fact-check against the live web',
252
+ # kind: 'optional - :cve | :version | :doc | :generic (default auto-detect)',
253
+ # url: 'optional - explicit URL the claim cites (forces kind: :doc)',
254
+ # commit: 'optional - write Mistakes/Memory/observe on refute/confirm (default true)',
255
+ # proxy: 'optional - upstream proxy for TransparentBrowser (e.g. "tor")'
256
+ # )
257
+ #
258
+ # Browser-backed self fact-checking. Drives PWN::Plugins::TransparentBrowser
259
+ # in :headless mode against a canonical source for the claim class,
260
+ # renders the DOM (JS executed), and returns:
261
+ #
262
+ # { claim:, kind:, verdict: :confirmed|:refuted|:unknown,
263
+ # confidence: 0.0..1.0,
264
+ # evidence: [{url:, title:, excerpt:, dom_sha:, screenshot:}],
265
+ # action_taken: :mistakes_record | :extro_observe | :learning_note | nil }
266
+ #
267
+ # On :refuted -> Mistakes.record(tool:'assumption', error:claim) so the
268
+ # KNOWN MISTAKES block warns every future run.
269
+ # On :confirmed-> observe(category::intel, ttl:30d) reinforces w/ freshness.
270
+ # On :unknown -> Learning.note_outcome(success:false, tags:['needs_human']).
271
+ #
272
+ # This is the extrospective mirror of Mistakes: a PROACTIVE trigger that
273
+ # catches the model being wrong about the world before a human does.
274
+
275
+ public_class_method def self.verify(opts = {})
276
+ claim = opts[:claim].to_s.strip
277
+ raise 'ERROR: claim is required' if claim.empty?
278
+
279
+ kind = (opts[:kind] || detect_claim_kind(claim: claim, url: opts[:url])).to_sym
280
+ commit = opts.key?(:commit) ? !opts[:commit].nil? && opts[:commit] != false : true
281
+ proxy = opts[:proxy] || web_config[:proxy]
282
+
283
+ evidence = []
284
+ verdict = :unknown
285
+ conf = 0.0
286
+
287
+ with_headless_browser(proxy: proxy) do |bo|
288
+ case kind
289
+ when :cve
290
+ cve = claim[/CVE-\d{4}-\d{4,}/i].to_s.upcase
291
+ %W[https://nvd.nist.gov/vuln/detail/#{cve} https://www.cve.org/CVERecord?id=#{cve}].each do |u|
292
+ fp = fingerprint_page(browser_obj: bo, url: u)
293
+ evidence << fp if fp
294
+ end
295
+ body = evidence.map { |e| e[:text].to_s }.join(' ')
296
+ if evidence.empty? || evidence.all? { |e| e[:status].to_i >= 400 } || body =~ /could not be found|does not exist|reserved but/i
297
+ verdict = :refuted
298
+ conf = 0.8
299
+ else
300
+ prod = claim.sub(/CVE-\d{4}-\d{4,}/i, '').gsub(/affects?|is|in|the/i, ' ').split.select { |w| w.length > 2 }
301
+ hit = prod.count { |w| body.downcase.include?(w.downcase) }
302
+ if prod.empty? || hit.to_f / prod.length >= 0.5
303
+ verdict = :confirmed
304
+ conf = prod.empty? ? 0.6 : [0.5 + (hit.to_f / prod.length * 0.5), 0.95].min
305
+ else
306
+ verdict = :refuted
307
+ conf = 0.6
308
+ end
309
+ end
310
+ when :doc
311
+ u = opts[:url] || claim[URI::DEFAULT_PARSER.make_regexp(%w[http https])]
312
+ fp = fingerprint_page(browser_obj: bo, url: u)
313
+ evidence << fp if fp
314
+ if fp.nil? || fp[:status].to_i >= 400 || fp[:reachable] == false
315
+ verdict = :refuted
316
+ conf = 0.7
317
+ else
318
+ snippet = claim.sub(u.to_s, '').strip
319
+ overlap = fuzzy_overlap(needle: snippet, haystack: fp[:text].to_s)
320
+ if fp[:title].to_s =~ /not found|404|error/i && overlap < 0.2
321
+ verdict = :refuted
322
+ conf = 0.7
323
+ else
324
+ verdict = overlap >= 0.4 ? :confirmed : :unknown
325
+ conf = overlap
326
+ end
327
+ end
328
+ when :version
329
+ proj = claim[/[A-Za-z][\w.+-]{2,}/].to_s
330
+ ver = claim[/v?\d+\.\d+(?:\.\d+)?/].to_s
331
+ %W[https://rubygems.org/gems/#{proj} https://pypi.org/project/#{proj}/ https://github.com/search?q=#{URI.encode_www_form_component(proj)}&type=repositories].each do |u|
332
+ fp = fingerprint_page(browser_obj: bo, url: u)
333
+ evidence << fp if fp
334
+ end
335
+ body = evidence.map { |e| e[:text].to_s }.join(' ')
336
+ if !ver.empty? && body.include?(ver)
337
+ verdict = :confirmed
338
+ conf = 0.7
339
+ elsif body =~ /\d+\.\d+(?:\.\d+)?/
340
+ verdict = :unknown
341
+ conf = 0.3
342
+ end
343
+ else
344
+ q = URI.encode_www_form_component(claim)
345
+ fp = fingerprint_page(browser_obj: bo, url: "https://html.duckduckgo.com/html/?q=#{q}")
346
+ evidence << fp if fp
347
+ overlap = fuzzy_overlap(needle: claim, haystack: fp ? fp[:text].to_s : '')
348
+ verdict = overlap >= 0.5 ? :confirmed : :unknown
349
+ conf = overlap
350
+ end
351
+ end
352
+
353
+ evidence.each { |e| e.delete(:text) }
354
+ action = commit ? commit_verdict(claim: claim, kind: kind, verdict: verdict, confidence: conf, evidence: evidence) : nil
355
+ { claim: claim, kind: kind, verdict: verdict, confidence: conf.round(2), evidence: evidence, action_taken: action }
356
+ rescue StandardError => e
357
+ { claim: claim, kind: kind, verdict: :unknown, confidence: 0.0, evidence: evidence, error: "#{e.class}: #{e.message}", action_taken: nil }
358
+ end
359
+
360
+ # Supported Method Parameters::
361
+ # obs = PWN::AI::Agent::Extrospection.watch(
362
+ # url: 'required - URL to render and fingerprint',
363
+ # selector: 'optional - CSS selector whose innerText to hash (default full body)',
364
+ # ttl: 'optional - seconds until stale (default 7 days)',
365
+ # proxy: 'optional - upstream proxy for TransparentBrowser'
366
+ # )
367
+ #
368
+ # Passive change-detection on an external artefact you care about
369
+ # (target /api/version, vendor changelog, bounty scope page).
370
+ # Renders headless, hashes the DOM text, screenshots, and persists as
371
+ # observe(category: :web). On subsequent snapshot(sections:[:web]) or
372
+ # watch of the same URL, a hash mismatch surfaces in drift() as
373
+ # ~web.<host>.dom_sha exactly like ~toolchain.nmap today.
374
+
375
+ public_class_method def self.watch(opts = {})
376
+ url = opts[:url].to_s
377
+ raise 'ERROR: url is required' if url.empty?
378
+
379
+ ttl = opts[:ttl] || (7 * 24 * 3600)
380
+ proxy = opts[:proxy] || web_config[:proxy]
381
+ fp = nil
382
+ with_headless_browser(proxy: proxy) do |bo|
383
+ fp = fingerprint_page(browser_obj: bo, url: url, selector: opts[:selector], screenshot: true)
384
+ end
385
+ raise "unreachable: #{url}" unless fp
386
+
387
+ prior = observations(category: 'web', target: url, limit: 1).first
388
+ changed = prior && prior.dig(:data, :dom_sha) && prior.dig(:data, :dom_sha) != fp[:dom_sha]
389
+ data = fp.slice(:status, :final_url, :title, :dom_sha, :cert_fp, :cert_not_after, :screenshot).merge(excerpt: fp[:text].to_s[0, 200])
390
+ observe(source: 'transparent_browser', category: :web, target: url, data: data, tags: (['watch'] + Array(opts[:tags])).compact, ttl: ttl)
391
+ { url: url, changed: changed, prior_sha: prior && prior.dig(:data, :dom_sha), current: data }
392
+ end
393
+
394
+ # Supported Method Parameters::
395
+ # report = PWN::AI::Agent::Extrospection.revalidate_memory(
396
+ # limit: 'optional - max :fact entries to check (default 25)',
397
+ # proxy: 'optional - upstream proxy for TransparentBrowser'
398
+ # )
399
+ #
400
+ # The browser as garbage-collector for PWN::Memory. Walks :fact entries
401
+ # containing a CVE id, version string or URL, runs verify() on each and
402
+ # prefixes stale ones with [UNVERIFIED yyyy-mm-dd] so the injected
403
+ # MEMORY block stops calcifying into confidently-wrong priors.
404
+ # Designed to be scheduled: cron_create(schedule:'0 4 * * 0',
405
+ # ruby:'PWN::AI::Agent::Extrospection.revalidate_memory')
406
+
407
+ public_class_method def self.revalidate_memory(opts = {})
408
+ return { checked: 0, refuted: [], confirmed: [], unknown: [] } unless defined?(PWN::Memory)
409
+
410
+ lim = opts[:limit] || 25
411
+ proxy = opts[:proxy] || web_config[:proxy]
412
+ rx = %r{(CVE-\d{4}-\d{4,}|v?\d+\.\d+\.\d+|https?://\S+)}i
413
+ out = { checked: 0, refuted: [], confirmed: [], unknown: [] }
414
+
415
+ PWN::Memory.load.each do |key, v|
416
+ break if out[:checked] >= lim
417
+ next unless v.is_a?(Hash) && v[:category].to_s == 'fact' && v[:value].to_s =~ rx
418
+ next if v[:value].to_s.start_with?('[UNVERIFIED')
419
+
420
+ res = verify(claim: v[:value].to_s[0, 400], commit: false, proxy: proxy)
421
+ out[:checked] += 1
422
+ out[res[:verdict]] << key
423
+ next unless res[:verdict] == :refuted && res[:confidence] >= 0.6
424
+
425
+ stamp = Time.now.utc.strftime('%Y-%m-%d')
426
+ PWN::Memory.remember(key: key, value: "[UNVERIFIED #{stamp}] #{v[:value]}", category: :fact)
427
+ observe(source: 'extro_verify', category: :web, target: key.to_s, data: "Memory :fact '#{key}' failed re-validation (#{res[:confidence]})", tags: %w[stale memory])
428
+ end
429
+ out
430
+ end
431
+
239
432
  # Supported Method Parameters::
240
433
  # findings = PWN::AI::Agent::Extrospection.correlate(
241
434
  # limit: 'optional - max findings returned (default 10)'
@@ -288,7 +481,54 @@ module PWN
288
481
  end
289
482
  end
290
483
 
291
- # 4) raw drift as low-priority findings when nothing else matched
484
+ # 4) :rf observations vs missing SDR hardware / binaries
485
+ rf = snap[:rf] || {}
486
+ hw_present = %i[rtl_sdr hackrf flipper gqrx_sock].any? { |k| rf_present?(val: rf[k]) }
487
+ observations(category: 'rf', limit: 50).each do |ob|
488
+ miss = RF_BINS.select { |b| pkgs[b.to_sym].to_s.empty? }
489
+ if !hw_present
490
+ findings << { kind: :rf_no_hardware, observation: ob[:data], source: ob[:source], target: ob[:target], advice: 'RF observation recorded but no SDR hardware detected in snapshot — plug in RTL-SDR/HackRF/Flipper or start gqrx (`-r`) before trusting RF results.' }
491
+ elsif rf[:gqrx_sock] == false && ob[:source].to_s == 'gqrx'
492
+ findings << { kind: :rf_gqrx_down, observation: ob[:data], target: ob[:target], advice: 'gqrx remote-control socket (127.0.0.1:7356) is closed — start gqrx with remote control enabled before re-running the scan.' }
493
+ elsif !miss.empty?
494
+ findings << { kind: :rf_toolchain_gap, missing: miss, observation: ob[:data], advice: "SDR toolchain gap: install #{miss.join(', ')} to decode/act on this RF observation." }
495
+ end
496
+ end
497
+
498
+ # 5) :web drift on a target the same day a Learning failure references it —
499
+ # "your exploit stopped working because the TARGET changed, not your approach"
500
+ if defined?(Learning)
501
+ observations(category: 'web', limit: 50).each do |ob|
502
+ tgt = ob[:target].to_s
503
+ host = safe_host(url: tgt)
504
+ Learning.outcomes(limit: 30, success: false).each do |o|
505
+ next unless o[:task].to_s.include?(host) || o[:details].to_s.include?(host)
506
+
507
+ findings << { kind: :target_web_drift, target: tgt, dom_sha: ob.dig(:data, :dom_sha), task: o[:task], advice: "Target #{host} DOM changed (#{ob[:timestamp]}) around this failure — re-recon before assuming your technique is wrong." }
508
+ end
509
+ end
510
+ end
511
+
512
+ # 6) extro_verify refutations whose claim substring appears in a PWN::Memory :fact -> stale memory
513
+ if defined?(PWN::Memory)
514
+ observations(category: 'web', tag: 'stale', limit: 50).each do |ob|
515
+ findings << { kind: :stale_memory_fact, key: ob[:target], evidence: ob[:data], advice: "PWN::Memory[:#{ob[:target]}] failed browser re-verification — audit or memory_forget it before it poisons future prompts." }
516
+ end
517
+ end
518
+
519
+ # 7) :intel observations whose source anchor is currently probe_web-unreachable -> downgrade
520
+ web = snap[:web] || {}
521
+ web.each do |host, fp|
522
+ next unless fp.is_a?(Hash) && (fp[:reachable] == false || fp[:status].to_i >= 500)
523
+
524
+ observations(category: 'intel', limit: 100).each do |ob|
525
+ next unless ob[:source].to_s.include?(host.to_s) || ob[:data].to_s.include?(host.to_s)
526
+
527
+ findings << { kind: :intel_source_unreachable, source: host, status: fp[:status], intel: ob[:data].to_s[0, 120], advice: "Feed anchor #{host} is currently unreachable (#{fp[:status] || 'down'}) — treat this :intel as stale until probe_web sees it 2xx again." }
528
+ end
529
+ end
530
+
531
+ # 8) raw drift as low-priority findings when nothing else matched
292
532
  Array(delta[:added]).first(5).each { |c| findings << { kind: :env_added, detail: c } } if findings.empty?
293
533
  Array(delta[:removed]).first(5).each { |c| findings << { kind: :env_removed, detail: c } } if findings.empty?
294
534
 
@@ -346,7 +586,9 @@ module PWN
346
586
  drift_added: Array(delta[:added]).length,
347
587
  drift_removed: Array(delta[:removed]).length,
348
588
  toolchain_bins: (snap[:toolchain] || {}).count { |_, v| !v.to_s.empty? },
349
- listening_ports: Array(snap.dig(:net, :listening)).length
589
+ listening_ports: Array(snap.dig(:net, :listening)).length,
590
+ rf_devices: (snap[:rf] || {}).values_at(:rtl_sdr, :hackrf, :flipper, :gqrx_sock).count { |v| rf_present?(val: v) },
591
+ web_anchors: (snap[:web] || {}).count { |_, v| v.is_a?(Hash) && v[:reachable] }
350
592
  }
351
593
  end
352
594
 
@@ -478,6 +720,243 @@ module PWN
478
720
  { cwd: Dir.pwd, ruby: RUBY_VERSION }
479
721
  end
480
722
 
723
+ # Passive RF / SDR hardware inventory. NO transmit, NO active spectrum
724
+ # scan — this is the RF analogue of probe_toolchain: "what radios and
725
+ # SDR plumbing are attached / reachable right now?" so drift can flag
726
+ # "HackRF unplugged", "gqrx remote-control down", "new RTL dongle".
727
+ private_class_method def self.probe_rf
728
+ {
729
+ rtl_sdr: sh(cmd: 'timeout 3 rtl_test -t 2>&1 | head -5'),
730
+ hackrf: sh(cmd: 'timeout 3 hackrf_info 2>&1 | head -8'),
731
+ soapy: sh(cmd: 'timeout 3 SoapySDRUtil --find 2>&1 | head -10'),
732
+ gqrx_sock: tcp_open?(host: '127.0.0.1', port: 7356),
733
+ flipper: Dir.glob('/dev/serial/by-id/*Flipper*').any?,
734
+ serial_devs: Dir.glob('/dev/{ttyUSB,ttyACM}*'),
735
+ band_plans: rf_band_plan_keys.length
736
+ }
737
+ rescue StandardError => e
738
+ { error: "#{e.class}: #{e.message}" }
739
+ end
740
+
741
+ private_class_method def self.tcp_open?(opts = {})
742
+ host = opts[:host] || '127.0.0.1'
743
+ port = opts[:port].to_i
744
+ Socket.tcp(host, port, connect_timeout: 1, &:close)
745
+ true
746
+ rescue StandardError
747
+ false
748
+ end
749
+
750
+ private_class_method def self.rf_present?(opts = {})
751
+ v = opts[:val]
752
+ return false if v.nil? || v == false || v.to_s.strip.empty?
753
+
754
+ v.to_s !~ /no\s+.*devices|no\s+hackrf|not\s+found|no\s+such\s+file|command\s+not\s+found|^false$/i
755
+ end
756
+
757
+ private_class_method def self.rf_band_plan_keys
758
+ require 'pwn/sdr/frequency_allocation' unless defined?(PWN::SDR::FrequencyAllocation)
759
+ PWN::SDR::FrequencyAllocation.band_plans.keys.map(&:to_s).sort
760
+ rescue StandardError
761
+ []
762
+ end
763
+
764
+ # Rendered-DOM fingerprint of a small, config-declared set of canonical
765
+ # web anchors the agent depends on for truth (feed reachability,
766
+ # upstream drift, in-scope target tech fingerprint). OPT-IN — never
767
+ # part of the default auto_extrospect section set because a headless
768
+ # browser is ~1-3s cold vs ~50ms for probe_host.
769
+ private_class_method def self.probe_web
770
+ anchors = web_anchors
771
+ return { anchors: 0 } if anchors.empty?
772
+
773
+ out = {}
774
+ with_headless_browser(proxy: web_config[:proxy]) do |bo|
775
+ anchors.first(web_config[:max_anchors]).each do |u|
776
+ host = safe_host(url: u).tr('.', '_')
777
+ fp = fingerprint_page(browser_obj: bo, url: u, screenshot: web_config[:screenshot])
778
+ out[host.to_sym] = fp ? fp.slice(:status, :final_url, :title, :dom_sha, :server, :generator, :cert_fp, :cert_not_after, :reachable, :screenshot) : { reachable: false }
779
+ end
780
+ end
781
+ out
782
+ rescue StandardError => e
783
+ { error: "#{e.class}: #{e.message}" }
784
+ end
785
+
786
+ private_class_method def self.web_config
787
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :web) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
788
+ {
789
+ anchors: Array(cfg[:anchors] || cfg[:web_anchors]),
790
+ proxy: cfg[:proxy],
791
+ max_anchors: (cfg[:max_anchors] || 8).to_i,
792
+ per_page_timeout: (cfg[:per_page_timeout] || 15).to_i,
793
+ screenshot: cfg.key?(:screenshot) ? !cfg[:screenshot].nil? && cfg[:screenshot] != false : false,
794
+ allow_targets: cfg[:allow_targets] ? true : false
795
+ }
796
+ rescue StandardError
797
+ { anchors: [], proxy: nil, max_anchors: 8, per_page_timeout: 15, screenshot: false, allow_targets: false }
798
+ end
799
+
800
+ private_class_method def self.web_anchors
801
+ a = web_config[:anchors]
802
+ a = DEFAULT_WEB_ANCHORS.dup if a.empty?
803
+ a += Array(PWN::Env[:targets]) if defined?(PWN::Env) && PWN::Env.is_a?(Hash) && web_config[:allow_targets]
804
+ a.compact.map(&:to_s).reject(&:empty?).uniq
805
+ rescue StandardError
806
+ DEFAULT_WEB_ANCHORS.dup
807
+ end
808
+
809
+ private_class_method def self.safe_host(opts = {})
810
+ URI.parse(opts[:url].to_s).host || opts[:url].to_s
811
+ rescue StandardError
812
+ opts[:url].to_s
813
+ end
814
+
815
+ # One shared headless browser per call — open once, reuse across
816
+ # anchors, always close in ensure. Prefers :headless (firefox) which
817
+ # is lightest; degrades to :rest (RestClient) if watir/selenium is
818
+ # unavailable so probe_web/verify/watch never hard-fail the loop.
819
+ private_class_method def self.with_headless_browser(opts = {})
820
+ require 'pwn/plugins/transparent_browser'
821
+ bo = nil
822
+ begin
823
+ bo = PWN::Plugins::TransparentBrowser.open(browser_type: :headless, proxy: opts[:proxy])
824
+ rescue StandardError
825
+ bo = PWN::Plugins::TransparentBrowser.open(browser_type: :rest, proxy: opts[:proxy])
826
+ end
827
+ yield bo
828
+ ensure
829
+ begin
830
+ PWN::Plugins::TransparentBrowser.close(browser_obj: bo) if bo
831
+ rescue StandardError
832
+ nil
833
+ end
834
+ end
835
+
836
+ private_class_method def self.fingerprint_page(opts = {})
837
+ bo = opts[:browser_obj]
838
+ url = opts[:url].to_s
839
+ return nil if bo.nil? || url.empty?
840
+
841
+ tmo = web_config[:per_page_timeout]
842
+ b = bo[:browser]
843
+ if bo[:type] == :rest
844
+ resp = b.get(url) { |r, _rq, _res| r }
845
+ body = resp.body.to_s
846
+ require 'nokogiri'
847
+ doc = Nokogiri::HTML.parse(body)
848
+ text = doc.text.to_s
849
+ cfp, cna = tls_fingerprint(url: url)
850
+ return { url: url, final_url: url, status: resp.code.to_i, title: doc.title.to_s,
851
+ server: resp.headers[:server].to_s, generator: doc.at('meta[name="generator"]')&.[]('content').to_s,
852
+ dom_sha: Digest::SHA256.hexdigest(text)[0, 16], text: text, cert_fp: cfp,
853
+ cert_not_after: cna, reachable: resp.code.to_i < 400 }
854
+ end
855
+ Timeout.timeout(tmo) { b.goto(url) }
856
+ text = extract_text(browser: b, selector: opts[:selector])
857
+ title = safe_attr(browser: b, meth: :title)
858
+ final = safe_attr(browser: b, meth: :url) || url
859
+ gen = safe_meta(browser: b, name: 'generator')
860
+ shot = opts[:screenshot] ? capture_screenshot(browser: b, url: url) : nil
861
+ cfp, cna = tls_fingerprint(url: final)
862
+ { url: url, final_url: final, status: 200, title: title.to_s, generator: gen.to_s, server: nil,
863
+ dom_sha: Digest::SHA256.hexdigest(text.to_s)[0, 16], text: text.to_s,
864
+ cert_fp: cfp, cert_not_after: cna, screenshot: shot, reachable: true }
865
+ rescue StandardError => e
866
+ { url: url, reachable: false, status: 0, error: "#{e.class}: #{e.message.to_s[0, 120]}", text: '' }
867
+ end
868
+
869
+ private_class_method def self.extract_text(opts = {})
870
+ b = opts[:browser]
871
+ sel = opts[:selector]
872
+ return b.element(css: sel).text if sel && b.element(css: sel).exists?
873
+
874
+ b.text
875
+ rescue StandardError
876
+ b.respond_to?(:text) ? b.text.to_s : ''
877
+ end
878
+
879
+ private_class_method def self.safe_attr(opts = {})
880
+ opts[:browser].public_send(opts[:meth])
881
+ rescue StandardError
882
+ nil
883
+ end
884
+
885
+ private_class_method def self.safe_meta(opts = {})
886
+ el = opts[:browser].element(css: "meta[name=\"#{opts[:name]}\"]")
887
+ el.exists? ? el.attribute_value('content') : ''
888
+ rescue StandardError
889
+ ''
890
+ end
891
+
892
+ private_class_method def self.capture_screenshot(opts = {})
893
+ FileUtils.mkdir_p(WEB_SHOT_DIR)
894
+ host = safe_host(url: opts[:url]).gsub(/[^\w.-]/, '_')
895
+ path = File.join(WEB_SHOT_DIR, "#{host}.png")
896
+ opts[:browser].screenshot.save(path)
897
+ path
898
+ rescue StandardError
899
+ nil
900
+ end
901
+
902
+ private_class_method def self.tls_fingerprint(opts = {})
903
+ u = URI.parse(opts[:url].to_s)
904
+ return [nil, nil] unless u.scheme == 'https'
905
+
906
+ require 'openssl'
907
+ ssl = tcp = nil
908
+ tcp = TCPSocket.new(u.host, u.port || 443)
909
+ ctx = OpenSSL::SSL::SSLContext.new
910
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
911
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
912
+ ssl.hostname = u.host
913
+ ssl.connect
914
+ cert = ssl.peer_cert
915
+ [Digest::SHA256.hexdigest(cert.to_der)[0, 16], cert.not_after.utc.iso8601]
916
+ rescue StandardError
917
+ [nil, nil]
918
+ ensure
919
+ ssl&.close
920
+ tcp&.close
921
+ end
922
+
923
+ private_class_method def self.fuzzy_overlap(opts = {})
924
+ needle = opts[:needle].to_s.downcase.scan(/[a-z0-9]{3,}/).uniq
925
+ hay = opts[:haystack].to_s.downcase
926
+ return 0.0 if needle.empty?
927
+
928
+ hit = needle.count { |w| hay.include?(w) }
929
+ hit.to_f / needle.length
930
+ end
931
+
932
+ private_class_method def self.detect_claim_kind(opts = {})
933
+ c = opts[:claim].to_s
934
+ return :doc if opts[:url] || c =~ URI::DEFAULT_PARSER.make_regexp(%w[http https])
935
+ return :cve if c =~ /CVE-\d{4}-\d{4,}/i
936
+ return :version if c =~ /\bv?\d+\.\d+(?:\.\d+)?\b/
937
+
938
+ :generic
939
+ end
940
+
941
+ private_class_method def self.commit_verdict(opts = {})
942
+ claim = opts[:claim]
943
+ ev = Array(opts[:evidence]).first
944
+ case opts[:verdict]
945
+ when :refuted
946
+ Mistakes.record(tool: 'assumption', error: "REFUTED (extro_verify #{opts[:kind]}, conf=#{opts[:confidence].round(2)}): #{claim}", args: ev&.dig(:final_url), source: :model) if defined?(Mistakes)
947
+ observe(source: 'extro_verify', category: :web, target: ev&.dig(:final_url), data: "REFUTED: #{claim}", tags: %w[verify refuted], ttl: 30 * 24 * 3600)
948
+ :mistakes_record
949
+ when :confirmed
950
+ observe(source: 'extro_verify', category: :intel, target: ev&.dig(:final_url), data: claim, tags: %w[verify confirmed], ttl: 30 * 24 * 3600)
951
+ :extro_observe
952
+ else
953
+ Learning.note_outcome(task: "extro_verify: #{claim[0, 120]}", success: false, details: 'verdict :unknown — needs human review', tags: %w[needs_human extro_verify]) if defined?(Learning) && Learning.respond_to?(:note_outcome)
954
+ :learning_note
955
+ end
956
+ rescue StandardError
957
+ nil
958
+ end
959
+
481
960
  private_class_method def self.flatten(opts = {})
482
961
  hash = opts[:hash] || {}
483
962
  prefix = opts[:prefix].to_s
@@ -598,17 +1077,26 @@ module PWN
598
1077
  PWN::AI::Agent::Extrospection.snapshot # probe host, persist, return {snapshot:, drift:}
599
1078
  PWN::AI::Agent::Extrospection.drift(live: true) # what changed vs last snapshot
600
1079
  PWN::AI::Agent::Extrospection.observe(source: 'nmap', category: :recon, target: '10.0.0.5', data: '22/tcp open ssh 9.6')
1080
+ PWN::AI::Agent::Extrospection.observe(source: 'gqrx', category: :rf, target: '433.920MHz', data: 'peak -34.2 dBFS bw=200k')
601
1081
  PWN::AI::Agent::Extrospection.observations(category: 'recon', target: '10.0.0.5')
602
1082
  PWN::AI::Agent::Extrospection.intel(query: 'openssl 3.0', record: true)
603
1083
  PWN::AI::Agent::Extrospection.correlate # introspection x extrospection findings
604
1084
  PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
605
1085
  PWN::AI::Agent::Extrospection.stats
606
1086
  PWN::AI::Agent::Extrospection.auto_extrospect(session_id: sid) # called from Learning.auto_reflect
1087
+ PWN::AI::Agent::Extrospection.snapshot(sections: %i[web]) # opt-in browser probe of web_anchors
1088
+ PWN::AI::Agent::Extrospection.watch(url: 'https://target/api/version')
1089
+ PWN::AI::Agent::Extrospection.verify(claim: 'CVE-2026-12345 affects OpenSSL 3.2.1')
1090
+ PWN::AI::Agent::Extrospection.revalidate_memory # cron: GC stale PWN::Memory :fact entries
607
1091
  PWN::AI::Agent::Extrospection.reset
608
1092
 
609
1093
  Enable end-of-run auto-extrospection with:
610
1094
  PWN::Env[:ai][:agent][:auto_extrospect] = true
611
1095
 
1096
+ Configure browser-backed :web probe / verify / watch with:
1097
+ PWN::Env[:ai][:agent][:extrospection][:web] =
1098
+ { anchors: [...], proxy: 'tor', max_anchors: 8, per_page_timeout: 15, screenshot: false, allow_targets: false }
1099
+
612
1100
  #{self}.authors
613
1101
  USAGE
614
1102
  end