pwn 0.5.620 → 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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Cron.md +13 -0
- data/documentation/Diagrams.md +1 -1
- data/documentation/Extrospection.md +71 -5
- data/documentation/Home.md +2 -2
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Mistakes.md +2 -1
- data/documentation/Persistence.md +2 -1
- data/documentation/Skills-Memory-Learning.md +1 -0
- data/documentation/Transparent-Browser.md +18 -1
- data/documentation/diagrams/agent-tool-registry.svg +55 -54
- data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
- data/documentation/diagrams/aws-cloud-security.svg +25 -25
- data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
- data/documentation/diagrams/code-scanning-sast.svg +25 -25
- data/documentation/diagrams/cron-scheduling.svg +23 -23
- data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +29 -8
- data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -0
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +2 -2
- data/documentation/diagrams/dot/persistence-filesystem.dot +2 -1
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
- data/documentation/diagrams/driver-framework.svg +13 -13
- data/documentation/diagrams/extrospection-world-awareness.svg +229 -99
- data/documentation/diagrams/fuzzing-workflow.svg +24 -24
- data/documentation/diagrams/hardware-hacking.svg +18 -18
- data/documentation/diagrams/history-to-drivers.svg +18 -18
- data/documentation/diagrams/memory-skills-detailed.svg +124 -101
- data/documentation/diagrams/mistakes-negative-feedback.svg +55 -55
- data/documentation/diagrams/network-infra-testing.svg +27 -27
- data/documentation/diagrams/overall-pwn-architecture.svg +85 -85
- data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
- data/documentation/diagrams/persistence-filesystem.svg +94 -85
- data/documentation/diagrams/plugin-ecosystem.svg +35 -35
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +226 -180
- data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
- data/documentation/diagrams/reporting-pipeline.svg +18 -18
- data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
- data/documentation/diagrams/sdr-radio-flow.svg +31 -31
- data/documentation/diagrams/sessions-cron-automation.svg +18 -18
- data/documentation/diagrams/swarm-multi-agent.svg +39 -39
- data/documentation/diagrams/web-application-testing.svg +26 -26
- data/documentation/diagrams/zero-day-research-flow.svg +25 -25
- data/documentation/pwn-ai-Agent.md +1 -1
- data/lib/pwn/ai/agent/extrospection.rb +433 -4
- data/lib/pwn/ai/agent/tools/extrospection.rb +88 -3
- data/lib/pwn/sdr/decoder/adsb.rb +14 -33
- data/lib/pwn/sdr/decoder/apt.rb +97 -36
- data/lib/pwn/sdr/decoder/base.rb +258 -171
- data/lib/pwn/sdr/decoder/bluetooth.rb +13 -20
- data/lib/pwn/sdr/decoder/dect.rb +13 -27
- data/lib/pwn/sdr/decoder/dsp.rb +396 -0
- data/lib/pwn/sdr/decoder/flex.rb +177 -210
- data/lib/pwn/sdr/decoder/gps.rb +12 -21
- data/lib/pwn/sdr/decoder/gsm.rb +21 -73
- data/lib/pwn/sdr/decoder/iridium.rb +11 -26
- data/lib/pwn/sdr/decoder/lora.rb +16 -23
- data/lib/pwn/sdr/decoder/lte.rb +11 -32
- data/lib/pwn/sdr/decoder/morse.rb +95 -21
- data/lib/pwn/sdr/decoder/p25.rb +19 -16
- data/lib/pwn/sdr/decoder/pager.rb +22 -38
- data/lib/pwn/sdr/decoder/pocsag.rb +175 -68
- data/lib/pwn/sdr/decoder/rfid.rb +24 -33
- data/lib/pwn/sdr/decoder/rtl433.rb +21 -30
- data/lib/pwn/sdr/decoder/rtty.rb +100 -27
- data/lib/pwn/sdr/decoder/wifi.rb +21 -42
- data/lib/pwn/sdr/decoder/zigbee.rb +18 -40
- data/lib/pwn/sdr/decoder.rb +8 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
- data/third_party/pwn_rdoc.jsonl +24 -3
- metadata +3 -1
|
@@ -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
|
|
@@ -43,6 +44,12 @@ module PWN
|
|
|
43
44
|
MAX_OBSERVATIONS = 500
|
|
44
45
|
RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
|
|
45
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
|
|
46
53
|
|
|
47
54
|
# Supported Method Parameters::
|
|
48
55
|
# store = PWN::AI::Agent::Extrospection.load
|
|
@@ -72,7 +79,7 @@ module PWN
|
|
|
72
79
|
# Supported Method Parameters::
|
|
73
80
|
# snap = PWN::AI::Agent::Extrospection.snapshot(
|
|
74
81
|
# persist: 'optional - Boolean, write snapshot to disk & rotate previous (default true)',
|
|
75
|
-
# sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf] (default all)'
|
|
82
|
+
# sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf, :web] (default all except :web)'
|
|
76
83
|
# )
|
|
77
84
|
#
|
|
78
85
|
# Captures a fingerprint of the OUTSIDE world. When persist:true the
|
|
@@ -94,6 +101,7 @@ module PWN
|
|
|
94
101
|
snap[:repo] = probe_repo if sections.include?(:repo)
|
|
95
102
|
snap[:env] = probe_env if sections.include?(:env)
|
|
96
103
|
snap[:rf] = probe_rf if sections.include?(:rf)
|
|
104
|
+
snap[:web] = probe_web if sections.include?(:web)
|
|
97
105
|
snap[:captured_at] = Time.now.utc.iso8601
|
|
98
106
|
snap[:fingerprint] = Digest::SHA256.hexdigest(JSON.generate(snap.except(:captured_at)))[0, 16]
|
|
99
107
|
|
|
@@ -112,7 +120,7 @@ module PWN
|
|
|
112
120
|
# obs = PWN::AI::Agent::Extrospection.observe(
|
|
113
121
|
# source: 'required - where the observation came from (nmap, shodan, burp, cve, human, ...)',
|
|
114
122
|
# data: 'required - the observation payload (String or Hash)',
|
|
115
|
-
# category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :misc (default :misc)',
|
|
123
|
+
# category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :web, :misc (default :misc)',
|
|
116
124
|
# target: 'optional - host/ip/url/asset the observation is about',
|
|
117
125
|
# tags: 'optional - Array of String labels',
|
|
118
126
|
# ttl: 'optional - seconds until this observation is considered stale (default nil = forever)'
|
|
@@ -238,6 +246,189 @@ module PWN
|
|
|
238
246
|
{ query: query, feeds: feeds, results: results, total: results.values.flatten.compact.length }
|
|
239
247
|
end
|
|
240
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
|
+
|
|
241
432
|
# Supported Method Parameters::
|
|
242
433
|
# findings = PWN::AI::Agent::Extrospection.correlate(
|
|
243
434
|
# limit: 'optional - max findings returned (default 10)'
|
|
@@ -304,7 +495,40 @@ module PWN
|
|
|
304
495
|
end
|
|
305
496
|
end
|
|
306
497
|
|
|
307
|
-
# 5)
|
|
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
|
|
308
532
|
Array(delta[:added]).first(5).each { |c| findings << { kind: :env_added, detail: c } } if findings.empty?
|
|
309
533
|
Array(delta[:removed]).first(5).each { |c| findings << { kind: :env_removed, detail: c } } if findings.empty?
|
|
310
534
|
|
|
@@ -363,7 +587,8 @@ module PWN
|
|
|
363
587
|
drift_removed: Array(delta[:removed]).length,
|
|
364
588
|
toolchain_bins: (snap[:toolchain] || {}).count { |_, v| !v.to_s.empty? },
|
|
365
589
|
listening_ports: Array(snap.dig(:net, :listening)).length,
|
|
366
|
-
rf_devices: (snap[:rf] || {}).values_at(:rtl_sdr, :hackrf, :flipper, :gqrx_sock).count { |v| rf_present?(val: v) }
|
|
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] }
|
|
367
592
|
}
|
|
368
593
|
end
|
|
369
594
|
|
|
@@ -536,6 +761,202 @@ module PWN
|
|
|
536
761
|
[]
|
|
537
762
|
end
|
|
538
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
|
+
|
|
539
960
|
private_class_method def self.flatten(opts = {})
|
|
540
961
|
hash = opts[:hash] || {}
|
|
541
962
|
prefix = opts[:prefix].to_s
|
|
@@ -663,11 +1084,19 @@ module PWN
|
|
|
663
1084
|
PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
|
|
664
1085
|
PWN::AI::Agent::Extrospection.stats
|
|
665
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
|
|
666
1091
|
PWN::AI::Agent::Extrospection.reset
|
|
667
1092
|
|
|
668
1093
|
Enable end-of-run auto-extrospection with:
|
|
669
1094
|
PWN::Env[:ai][:agent][:auto_extrospect] = true
|
|
670
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
|
+
|
|
671
1100
|
#{self}.authors
|
|
672
1101
|
USAGE
|
|
673
1102
|
end
|
|
@@ -29,7 +29,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
29
29
|
type: 'object',
|
|
30
30
|
properties: {
|
|
31
31
|
persist: { type: 'boolean', default: true, description: 'Write to disk & rotate previous baseline.' },
|
|
32
|
-
sections: { type: 'array', items: { type: 'string', enum: %w[host net toolchain repo env rf] }, description: 'Subset of probes to run (default all).' }
|
|
32
|
+
sections: { type: 'array', items: { type: 'string', enum: %w[host net toolchain repo env rf web] }, description: 'Subset of probes to run (default all).' }
|
|
33
33
|
},
|
|
34
34
|
required: []
|
|
35
35
|
}
|
|
@@ -86,7 +86,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
86
86
|
properties: {
|
|
87
87
|
source: { type: 'string', description: 'Where it came from (nmap, shodan, burp, cve, human, ...).' },
|
|
88
88
|
data: { type: 'string', description: 'The observation itself.' },
|
|
89
|
-
category: { type: 'string', enum: %w[recon vuln intel target network env rf misc], default: 'misc' },
|
|
89
|
+
category: { type: 'string', enum: %w[recon vuln intel target network env rf web misc], default: 'misc' },
|
|
90
90
|
target: { type: 'string', description: 'Host / IP / URL / asset the observation is about.' },
|
|
91
91
|
tags: { type: 'array', items: { type: 'string' } },
|
|
92
92
|
ttl: { type: 'integer', description: 'Seconds until stale (omit = forever).' }
|
|
@@ -187,7 +187,10 @@ PWN::AI::Agent::Registry.register(
|
|
|
187
187
|
'against toolchain drift / missing binaries, (b) Learning ' \
|
|
188
188
|
'failures against host/net/repo drift on the same day, ' \
|
|
189
189
|
'(c) recorded :intel observations against installed ' \
|
|
190
|
-
'component versions
|
|
190
|
+
'component versions, (d) :web DOM drift on watched targets ' \
|
|
191
|
+
'against Learning failures citing that host, (e) refuted ' \
|
|
192
|
+
'extro_verify claims against stale PWN::Memory :fact ' \
|
|
193
|
+
'entries. Returns actionable findings so the ' \
|
|
191
194
|
'agent can distinguish "I did it wrong" from "the world ' \
|
|
192
195
|
'changed under me".',
|
|
193
196
|
parameters: {
|
|
@@ -278,3 +281,85 @@ PWN::AI::Agent::Registry.register(
|
|
|
278
281
|
{ previous: prev, current: ai[:agent][:auto_extrospect] ? true : false }
|
|
279
282
|
}
|
|
280
283
|
)
|
|
284
|
+
|
|
285
|
+
PWN::AI::Agent::Registry.register(
|
|
286
|
+
name: 'extro_verify',
|
|
287
|
+
toolset: 'extrospection',
|
|
288
|
+
schema: {
|
|
289
|
+
name: 'extro_verify',
|
|
290
|
+
description: 'Browser-backed SELF FACT-CHECK. Drives ' \
|
|
291
|
+
'PWN::Plugins::TransparentBrowser (:headless) against a ' \
|
|
292
|
+
'canonical source for the claim class (NVD/CVE.org for ' \
|
|
293
|
+
':cve, rubygems/PyPI/GitHub for :version, the cited URL ' \
|
|
294
|
+
'for :doc, DuckDuckGo HTML for :generic), renders the DOM ' \
|
|
295
|
+
'with JS executed, and returns {claim:, kind:, verdict: ' \
|
|
296
|
+
':confirmed|:refuted|:unknown, confidence:, evidence:[], ' \
|
|
297
|
+
'action_taken:}. On :refuted → Mistakes.record(tool:' \
|
|
298
|
+
"'assumption') so KNOWN MISTAKES warns every future run; " \
|
|
299
|
+
'on :confirmed → observe(:intel, ttl:30d); on :unknown → ' \
|
|
300
|
+
'Learning.note_outcome(tags:[needs_human]). This is the ' \
|
|
301
|
+
'PROACTIVE trigger that catches the model being wrong ' \
|
|
302
|
+
'about the world before a human does — the extrospective ' \
|
|
303
|
+
'mirror of mistakes_record.',
|
|
304
|
+
parameters: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: {
|
|
307
|
+
claim: { type: 'string', description: 'Factual claim to fact-check (a CVE assertion, "latest X is v1.2.3", a cited URL + quoted snippet, or free text).' },
|
|
308
|
+
kind: { type: 'string', enum: %w[cve version doc generic], description: 'Force a verifier. Omit to auto-detect from the claim.' },
|
|
309
|
+
url: { type: 'string', description: 'Explicit URL the claim cites (forces kind: :doc).' },
|
|
310
|
+
commit: { type: 'boolean', default: true, description: 'Write Mistakes/observe/Learning on the verdict.' },
|
|
311
|
+
proxy: { type: 'string', description: 'Upstream proxy for TransparentBrowser (e.g. "tor" or http://127.0.0.1:8080).' }
|
|
312
|
+
},
|
|
313
|
+
required: %w[claim]
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
check: -> { defined?(PWN::AI::Agent::Extrospection) && PWN::AI::Agent::Extrospection.respond_to?(:verify) },
|
|
317
|
+
handler: lambda { |args|
|
|
318
|
+
o = { claim: args[:claim] }
|
|
319
|
+
o[:kind] = args[:kind] if args[:kind]
|
|
320
|
+
o[:url] = args[:url] if args[:url]
|
|
321
|
+
o[:commit] = args[:commit] if args.key?(:commit)
|
|
322
|
+
o[:proxy] = args[:proxy] if args[:proxy]
|
|
323
|
+
PWN::AI::Agent::Extrospection.verify(o)
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
PWN::AI::Agent::Registry.register(
|
|
328
|
+
name: 'extro_watch',
|
|
329
|
+
toolset: 'extrospection',
|
|
330
|
+
schema: {
|
|
331
|
+
name: 'extro_watch',
|
|
332
|
+
description: 'Passive change-detection on an external web artefact you ' \
|
|
333
|
+
'care about (a target /api/version, a vendor changelog, a ' \
|
|
334
|
+
'bug-bounty scope page). Renders the URL headlessly via ' \
|
|
335
|
+
'PWN::Plugins::TransparentBrowser, hashes the RENDERED DOM ' \
|
|
336
|
+
'text (JS-delivered changes count), captures title / TLS ' \
|
|
337
|
+
'cert fp / screenshot, and persists it as observe(' \
|
|
338
|
+
'category: :web). Re-running against the same URL returns ' \
|
|
339
|
+
'{changed: true|false, prior_sha:, current:{…}}; a ' \
|
|
340
|
+
'subsequent extro_snapshot(sections:["web"]) surfaces the ' \
|
|
341
|
+
'delta in drift() as ~web.<host>.dom_sha exactly like ' \
|
|
342
|
+
'~toolchain.nmap today.',
|
|
343
|
+
parameters: {
|
|
344
|
+
type: 'object',
|
|
345
|
+
properties: {
|
|
346
|
+
url: { type: 'string', description: 'URL to render, hash and watch.' },
|
|
347
|
+
selector: { type: 'string', description: 'CSS selector whose innerText to hash (default full body).' },
|
|
348
|
+
ttl: { type: 'integer', description: 'Seconds until this :web observation is stale (default 604800 = 7d).' },
|
|
349
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
350
|
+
proxy: { type: 'string', description: 'Upstream proxy for TransparentBrowser (e.g. "tor").' }
|
|
351
|
+
},
|
|
352
|
+
required: %w[url]
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
check: -> { defined?(PWN::AI::Agent::Extrospection) && PWN::AI::Agent::Extrospection.respond_to?(:watch) },
|
|
356
|
+
handler: lambda { |args|
|
|
357
|
+
PWN::AI::Agent::Extrospection.watch(
|
|
358
|
+
url: args[:url],
|
|
359
|
+
selector: args[:selector],
|
|
360
|
+
ttl: args[:ttl],
|
|
361
|
+
tags: args[:tags],
|
|
362
|
+
proxy: args[:proxy]
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
)
|