pwn 0.5.627 → 0.5.628
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/.rubocop_todo.yml +20 -30
- data/Gemfile +1 -1
- data/README.md +2 -2
- data/lib/pwn/ai/agent/dispatch.rb +76 -4
- data/lib/pwn/ai/agent/learning.rb +124 -1
- data/lib/pwn/ai/agent/loop.rb +104 -12
- data/lib/pwn/ai/agent/metrics.rb +60 -27
- data/lib/pwn/ai/agent/prompt_builder.rb +63 -12
- data/lib/pwn/ai/agent/reflect.rb +41 -7
- data/lib/pwn/ai/agent/registry.rb +69 -4
- data/lib/pwn/ai/ollama.rb +39 -10
- data/lib/pwn/config.rb +11 -0
- data/lib/pwn/ffi/liquid.rb +179 -0
- data/lib/pwn/memory.rb +15 -0
- data/lib/pwn/memory_index.rb +233 -0
- data/lib/pwn/sdr/decoder/bluetooth.rb +141 -75
- data/lib/pwn/sdr/decoder/dect.rb +89 -63
- data/lib/pwn/sdr/decoder/dsp.rb +443 -0
- data/lib/pwn/sdr/decoder/gps.rb +93 -73
- data/lib/pwn/sdr/decoder/gsm.rb +167 -63
- data/lib/pwn/sdr/decoder/lora.rb +118 -65
- data/lib/pwn/sdr/decoder/lte.rb +188 -65
- data/lib/pwn/sdr/decoder/p25.rb +83 -67
- data/lib/pwn/sdr/decoder/zigbee.rb +140 -67
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +1 -0
- data/spec/lib/pwn/memory_index_spec.rb +15 -0
- data/third_party/pwn_rdoc.jsonl +65 -8
- metadata +6 -4
data/lib/pwn/ai/agent/metrics.rb
CHANGED
|
@@ -18,6 +18,15 @@ module PWN
|
|
|
18
18
|
# tool selection accordingly. This is one half of the closed
|
|
19
19
|
# feedback loop that lets pwn-ai continuously make itself smarter
|
|
20
20
|
# (the other half is PWN::AI::Agent::Learning).
|
|
21
|
+
#
|
|
22
|
+
# PER-ENGINE SEGMENTATION
|
|
23
|
+
# -----------------------
|
|
24
|
+
# A local qwen3.6:35b and a frontier model do NOT have the same
|
|
25
|
+
# per-tool success rate — blending them mis-advises the local model
|
|
26
|
+
# about itself. Every record now also increments an :engines[<engine>]
|
|
27
|
+
# sub-bucket; summary/to_context accept engine: to surface only that
|
|
28
|
+
# engine's telemetry so the TOOL EFFECTIVENESS block becomes a
|
|
29
|
+
# genuine per-engine learned policy.
|
|
21
30
|
module Metrics
|
|
22
31
|
METRICS_FILE = File.join(Dir.home, '.pwn', 'metrics.json')
|
|
23
32
|
|
|
@@ -51,7 +60,8 @@ module PWN
|
|
|
51
60
|
# name: 'required - tool name that was dispatched',
|
|
52
61
|
# success: 'required - Boolean, did the handler complete without error',
|
|
53
62
|
# duration: 'optional - Float seconds the dispatch took',
|
|
54
|
-
# error: 'optional - String error message when success is false'
|
|
63
|
+
# error: 'optional - String error message when success is false',
|
|
64
|
+
# engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)'
|
|
55
65
|
# )
|
|
56
66
|
|
|
57
67
|
public_class_method def self.record(opts = {})
|
|
@@ -59,65 +69,70 @@ module PWN
|
|
|
59
69
|
success = opts[:success] ? true : false
|
|
60
70
|
duration = opts[:duration].to_f
|
|
61
71
|
error = opts[:error]
|
|
72
|
+
engine = opts[:engine].to_s
|
|
62
73
|
return if name.empty?
|
|
63
74
|
|
|
64
75
|
metrics = load
|
|
65
76
|
metrics[:tools] ||= {}
|
|
66
77
|
key = name.to_sym
|
|
67
|
-
t = metrics[:tools][key] ||=
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
t[:total_duration] += duration
|
|
75
|
-
t[:last_error] = error.to_s[0, 300] if error && !success
|
|
76
|
-
t[:last_at] = Time.now.utc.iso8601
|
|
78
|
+
t = metrics[:tools][key] ||= blank_bucket
|
|
79
|
+
bump(bucket: t, success: success, duration: duration, error: error)
|
|
80
|
+
unless engine.empty?
|
|
81
|
+
t[:engines] ||= {}
|
|
82
|
+
e = t[:engines][engine.to_sym] ||= blank_bucket
|
|
83
|
+
bump(bucket: e, success: success, duration: duration, error: error)
|
|
84
|
+
end
|
|
77
85
|
save(metrics: metrics)
|
|
78
86
|
t
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
# Supported Method Parameters::
|
|
82
90
|
# rows = PWN::AI::Agent::Metrics.summary(
|
|
83
|
-
# limit: 'optional - cap number of tools returned (default 25)'
|
|
91
|
+
# limit: 'optional - cap number of tools returned (default 25)',
|
|
92
|
+
# engine: 'optional - only that engine\'s sub-bucket (falls back to global when absent)'
|
|
84
93
|
# )
|
|
85
94
|
|
|
86
95
|
public_class_method def self.summary(opts = {})
|
|
87
|
-
limit
|
|
88
|
-
|
|
96
|
+
limit = opts[:limit] || 25
|
|
97
|
+
engine = opts[:engine].to_s
|
|
98
|
+
tools = load[:tools] || {}
|
|
89
99
|
rows = tools.map do |name, t|
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
b = engine.empty? ? t : (t.dig(:engines, engine.to_sym) || t)
|
|
101
|
+
calls = b[:calls].to_i
|
|
102
|
+
ok = b[:ok].to_i
|
|
92
103
|
rate = calls.positive? ? (ok.to_f / calls).round(3) : 0.0
|
|
93
|
-
avg = calls.positive? ? (
|
|
104
|
+
avg = calls.positive? ? (b[:total_duration].to_f / calls).round(3) : 0.0
|
|
94
105
|
{
|
|
95
106
|
name: name.to_s,
|
|
96
107
|
calls: calls,
|
|
97
108
|
success_rate: rate,
|
|
98
109
|
avg_duration: avg,
|
|
99
|
-
last_error:
|
|
100
|
-
last_at:
|
|
110
|
+
last_error: b[:last_error],
|
|
111
|
+
last_at: b[:last_at]
|
|
101
112
|
}
|
|
102
113
|
end
|
|
103
|
-
rows.
|
|
114
|
+
rows.reject { |r| r[:calls].zero? }
|
|
115
|
+
.sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit)
|
|
104
116
|
end
|
|
105
117
|
|
|
106
118
|
# Supported Method Parameters::
|
|
107
119
|
# ctx = PWN::AI::Agent::Metrics.to_context(
|
|
108
|
-
# limit: 'optional - cap number of tools included (default 8)'
|
|
120
|
+
# limit: 'optional - cap number of tools included (default 8)',
|
|
121
|
+
# engine: 'optional - restrict to one engine\'s telemetry'
|
|
109
122
|
# )
|
|
110
123
|
|
|
111
124
|
public_class_method def self.to_context(opts = {})
|
|
112
|
-
limit
|
|
113
|
-
|
|
125
|
+
limit = opts[:limit] || 8
|
|
126
|
+
engine = opts[:engine]
|
|
127
|
+
rows = summary(limit: limit, engine: engine)
|
|
114
128
|
return '' if rows.empty?
|
|
115
129
|
|
|
130
|
+
scope = engine.to_s.empty? ? 'historical' : "engine=#{engine}"
|
|
116
131
|
lines = rows.map do |r|
|
|
117
132
|
err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : ''
|
|
118
133
|
" - #{r[:name]}: calls=#{r[:calls]} success=#{(r[:success_rate] * 100).round(1)}% avg=#{r[:avg_duration]}s#{err}"
|
|
119
134
|
end
|
|
120
|
-
"TOOL EFFECTIVENESS (
|
|
135
|
+
"TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
|
|
121
136
|
end
|
|
122
137
|
|
|
123
138
|
# Supported Method Parameters::
|
|
@@ -128,6 +143,24 @@ module PWN
|
|
|
128
143
|
{ tools: {}, updated_at: nil }
|
|
129
144
|
end
|
|
130
145
|
|
|
146
|
+
private_class_method def self.blank_bucket
|
|
147
|
+
{ calls: 0, ok: 0, fail: 0, total_duration: 0.0, last_error: nil, last_at: nil }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private_class_method def self.bump(opts = {})
|
|
151
|
+
b = opts[:bucket]
|
|
152
|
+
success = opts[:success]
|
|
153
|
+
duration = opts[:duration].to_f
|
|
154
|
+
error = opts[:error]
|
|
155
|
+
b[:calls] += 1
|
|
156
|
+
b[:ok] += 1 if success
|
|
157
|
+
b[:fail] += 1 unless success
|
|
158
|
+
b[:total_duration] = b[:total_duration].to_f + duration
|
|
159
|
+
b[:last_error] = error.to_s[0, 300] if error && !success
|
|
160
|
+
b[:last_at] = Time.now.utc.iso8601
|
|
161
|
+
b
|
|
162
|
+
end
|
|
163
|
+
|
|
131
164
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
132
165
|
|
|
133
166
|
public_class_method def self.authors
|
|
@@ -139,9 +172,9 @@ module PWN
|
|
|
139
172
|
public_class_method def self.help
|
|
140
173
|
puts <<~USAGE
|
|
141
174
|
USAGE:
|
|
142
|
-
PWN::AI::Agent::Metrics.record(name: 'shell', success: true, duration: 0.42)
|
|
143
|
-
PWN::AI::Agent::Metrics.summary(limit: 10)
|
|
144
|
-
PWN::AI::Agent::Metrics.to_context(limit: 8) # injected by PromptBuilder
|
|
175
|
+
PWN::AI::Agent::Metrics.record(name: 'shell', success: true, duration: 0.42, engine: :ollama)
|
|
176
|
+
PWN::AI::Agent::Metrics.summary(limit: 10, engine: :ollama)
|
|
177
|
+
PWN::AI::Agent::Metrics.to_context(limit: 8, engine: :ollama) # injected by PromptBuilder
|
|
145
178
|
PWN::AI::Agent::Metrics.reset
|
|
146
179
|
PWN::AI::Agent::Metrics.load
|
|
147
180
|
PWN::AI::Agent::Metrics.save(metrics: hash)
|
|
@@ -10,15 +10,33 @@ module PWN
|
|
|
10
10
|
# Re-injection IS the persistence mechanism: this is rebuilt fresh on
|
|
11
11
|
# every user turn, so a memory_remember / skill_create from the prior
|
|
12
12
|
# turn shows up here with no extra wiring.
|
|
13
|
+
#
|
|
14
|
+
# ENGINE-AWARE BUDGETING
|
|
15
|
+
# ----------------------
|
|
16
|
+
# Local models (Ollama) drown when handed the same 6-8 KB of MEMORY /
|
|
17
|
+
# METRICS / MISTAKES / EXTROSPECTION context that a frontier model
|
|
18
|
+
# shrugs off. .budget shrinks each block for :ollama (or whatever
|
|
19
|
+
# PWN::Env[:ai][<engine>][:prompt_budget] says) so the small model
|
|
20
|
+
# spends its attention on the request, not the harness.
|
|
21
|
+
#
|
|
22
|
+
# RELEVANCE-RANKED MEMORY
|
|
23
|
+
# -----------------------
|
|
24
|
+
# When Loop.run passes request: through, the MEMORY block is populated
|
|
25
|
+
# by PWN::MemoryIndex.recall_semantic (embedding cosine over
|
|
26
|
+
# ~/.pwn/memory.idx) instead of a recency dump — the 6 memories a
|
|
27
|
+
# small model can afford are the 6 that actually matter for THIS turn.
|
|
13
28
|
module PromptBuilder
|
|
14
29
|
# Supported Method Parameters::
|
|
15
30
|
# system_prompt = PWN::AI::Agent::PromptBuilder.build(
|
|
16
|
-
# session_id: 'optional - PWN::Sessions id to embed in the ENV block'
|
|
31
|
+
# session_id: 'optional - PWN::Sessions id to embed in the ENV block',
|
|
32
|
+
# request: 'optional - user request; enables relevance-ranked MEMORY when provided'
|
|
17
33
|
# )
|
|
18
34
|
|
|
19
35
|
public_class_method def self.build(opts = {})
|
|
20
36
|
session_id = opts[:session_id]
|
|
37
|
+
request = opts[:request]
|
|
21
38
|
engine = active_engine
|
|
39
|
+
b = budget
|
|
22
40
|
base = (PWN::Env.dig(:ai, engine, :system_role_content) if defined?(PWN::Env)) || 'You are a world-class introspective offensive cyber security and research engineer. You specialize in discovering zero day vulnerabilities focused on responsible disclosure prior to threat actors discovering and exploiting. You are self-aware of your harness, pwn which begins with the ruby namespace `PWN` operating inside the pwn REPL. For every request you first begin by determining if PWN has a module capable of satisfying the request.'
|
|
23
41
|
|
|
24
42
|
"
|
|
@@ -31,7 +49,7 @@ module PWN
|
|
|
31
49
|
pwn : #{pwn_version}
|
|
32
50
|
session_id : #{session_id || '(none)'}
|
|
33
51
|
|
|
34
|
-
#{memory_block}#{skills_block}#{learning_block}#{mistakes_block}#{metrics_block}#{extrospection_block}TOOL USE
|
|
52
|
+
#{memory_block(limit: b[:memory], request: request)}#{skills_block}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes])}#{metrics_block(limit: b[:metrics], engine: engine)}#{extrospection_block if b[:extro]}TOOL USE
|
|
35
53
|
Use the provided function tools to act on the host. A reply with
|
|
36
54
|
no tool_calls is treated as your FINAL answer to the user.
|
|
37
55
|
Prefer `pwn_eval` for anything in the PWN:: namespace and `shell`
|
|
@@ -39,6 +57,29 @@ module PWN
|
|
|
39
57
|
"
|
|
40
58
|
end
|
|
41
59
|
|
|
60
|
+
# Supported Method Parameters::
|
|
61
|
+
# b = PWN::AI::Agent::PromptBuilder.budget
|
|
62
|
+
#
|
|
63
|
+
# Per-engine caps for each injected block. Override any key via
|
|
64
|
+
# PWN::Env[:ai][<engine>][:prompt_budget][:memory|:metrics|:mistakes|
|
|
65
|
+
# :learning|:extro]. :extro is a Boolean gate — Extrospection is the
|
|
66
|
+
# heaviest block and rarely useful to a local model.
|
|
67
|
+
|
|
68
|
+
public_class_method def self.budget
|
|
69
|
+
eng = active_engine
|
|
70
|
+
b = (PWN::Env.dig(:ai, eng, :prompt_budget) if defined?(PWN::Env)) || {}
|
|
71
|
+
local = eng == :ollama
|
|
72
|
+
{
|
|
73
|
+
memory: (b[:memory] || (local ? 6 : 25)).to_i,
|
|
74
|
+
metrics: (b[:metrics] || (local ? 3 : 8)).to_i,
|
|
75
|
+
mistakes: (b[:mistakes] || (local ? 3 : 6)).to_i,
|
|
76
|
+
learning: (b[:learning] || (local ? 2 : 5)).to_i,
|
|
77
|
+
extro: b[:extro].nil? ? !local : b[:extro]
|
|
78
|
+
}
|
|
79
|
+
rescue StandardError
|
|
80
|
+
{ memory: 25, metrics: 8, mistakes: 6, learning: 5, extro: true }
|
|
81
|
+
end
|
|
82
|
+
|
|
42
83
|
private_class_method def self.active_engine
|
|
43
84
|
return :openai unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
|
|
44
85
|
|
|
@@ -57,11 +98,17 @@ module PWN
|
|
|
57
98
|
defined?(PWN::VERSION) ? PWN::VERSION : '?'
|
|
58
99
|
end
|
|
59
100
|
|
|
60
|
-
private_class_method def self.memory_block
|
|
101
|
+
private_class_method def self.memory_block(opts = {})
|
|
61
102
|
return '' unless defined?(PWN::Memory) && PWN::Memory.respond_to?(:to_context)
|
|
62
103
|
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
limit = opts[:limit] || 25
|
|
105
|
+
req = opts[:request]
|
|
106
|
+
ctx = if req && defined?(PWN::MemoryIndex) && PWN::MemoryIndex.available?
|
|
107
|
+
PWN::MemoryIndex.to_context(query: req, limit: limit)
|
|
108
|
+
else
|
|
109
|
+
PWN::Memory.to_context(limit: limit)
|
|
110
|
+
end
|
|
111
|
+
ctx.to_s.strip.empty? ? '' : "MEMORY#{ctx}\n\n"
|
|
65
112
|
rescue StandardError
|
|
66
113
|
''
|
|
67
114
|
end
|
|
@@ -81,28 +128,28 @@ module PWN
|
|
|
81
128
|
''
|
|
82
129
|
end
|
|
83
130
|
|
|
84
|
-
private_class_method def self.learning_block
|
|
131
|
+
private_class_method def self.learning_block(opts = {})
|
|
85
132
|
return '' unless defined?(PWN::AI::Agent::Learning)
|
|
86
133
|
|
|
87
|
-
ctx = PWN::AI::Agent::Learning.to_context(limit: 5).to_s
|
|
134
|
+
ctx = PWN::AI::Agent::Learning.to_context(limit: opts[:limit] || 5).to_s
|
|
88
135
|
ctx.strip.empty? ? '' : "LEARNING\n#{ctx}"
|
|
89
136
|
rescue StandardError
|
|
90
137
|
''
|
|
91
138
|
end
|
|
92
139
|
|
|
93
|
-
private_class_method def self.mistakes_block
|
|
140
|
+
private_class_method def self.mistakes_block(opts = {})
|
|
94
141
|
return '' unless defined?(PWN::AI::Agent::Mistakes)
|
|
95
142
|
|
|
96
|
-
ctx = PWN::AI::Agent::Mistakes.to_context(limit: 6).to_s
|
|
143
|
+
ctx = PWN::AI::Agent::Mistakes.to_context(limit: opts[:limit] || 6).to_s
|
|
97
144
|
ctx.strip.empty? ? '' : ctx
|
|
98
145
|
rescue StandardError
|
|
99
146
|
''
|
|
100
147
|
end
|
|
101
148
|
|
|
102
|
-
private_class_method def self.metrics_block
|
|
149
|
+
private_class_method def self.metrics_block(opts = {})
|
|
103
150
|
return '' unless defined?(PWN::AI::Agent::Metrics)
|
|
104
151
|
|
|
105
|
-
ctx = PWN::AI::Agent::Metrics.to_context(limit: 8).to_s
|
|
152
|
+
ctx = PWN::AI::Agent::Metrics.to_context(limit: opts[:limit] || 8, engine: opts[:engine]).to_s
|
|
106
153
|
ctx.strip.empty? ? '' : ctx
|
|
107
154
|
rescue StandardError
|
|
108
155
|
''
|
|
@@ -128,7 +175,11 @@ module PWN
|
|
|
128
175
|
public_class_method def self.help
|
|
129
176
|
puts <<~USAGE
|
|
130
177
|
USAGE:
|
|
131
|
-
system_prompt = PWN::AI::Agent::PromptBuilder.build(session_id: 'abc')
|
|
178
|
+
system_prompt = PWN::AI::Agent::PromptBuilder.build(session_id: 'abc', request: 'nmap sweep 10/8')
|
|
179
|
+
PWN::AI::Agent::PromptBuilder.budget # => {memory:, metrics:, mistakes:, learning:, extro:}
|
|
180
|
+
|
|
181
|
+
Override per-engine via:
|
|
182
|
+
PWN::Env[:ai][:ollama][:prompt_budget] = { memory: 4, extro: false }
|
|
132
183
|
|
|
133
184
|
#{self}.authors
|
|
134
185
|
USAGE
|
data/lib/pwn/ai/agent/reflect.rb
CHANGED
|
@@ -21,11 +21,21 @@ module PWN
|
|
|
21
21
|
# through when it wants an LLM opinion on locally-produced data, and
|
|
22
22
|
# it is also what PWN::AI::Agent::Learning.reflect uses to distill
|
|
23
23
|
# session transcripts into durable PWN::Memory lessons.
|
|
24
|
+
#
|
|
25
|
+
# TEACHER-STUDENT REFLECTION
|
|
26
|
+
# --------------------------
|
|
27
|
+
# When PWN::Env[:ai][:reflect_engine] (or opts[:engine]) names a
|
|
28
|
+
# different provider than :active, Reflect.on temporarily flips
|
|
29
|
+
# :active for the duration of the introspection call. This lets a
|
|
30
|
+
# local Ollama model EXECUTE the task while a frontier model WRITES
|
|
31
|
+
# the durable lessons about it — the local model then reads back
|
|
32
|
+
# distilled reasoning it could never have produced itself.
|
|
24
33
|
module Reflect
|
|
25
34
|
# Supported Method Parameters::
|
|
26
35
|
# response = PWN::AI::Agent::Reflect.on(
|
|
27
36
|
# request: 'required - String - What you want the AI to reflect on',
|
|
28
37
|
# system_role_content: 'optional - context to set up the model behavior for reflection',
|
|
38
|
+
# engine: 'optional - override engine for THIS reflection only (Symbol/String); defaults to PWN::Env[:ai][:reflect_engine] || :active',
|
|
29
39
|
# spinner: 'optional - Boolean - Display spinner during operation (default: false)',
|
|
30
40
|
# suppress_pii_warning: 'optional - Boolean - Suppress PII Warnings (default: false)'
|
|
31
41
|
# )
|
|
@@ -45,17 +55,20 @@ module PWN
|
|
|
45
55
|
ai_module_reflection = PWN::Env[:ai][:module_reflection]
|
|
46
56
|
|
|
47
57
|
if ai_module_reflection && request.length.positive?
|
|
48
|
-
|
|
58
|
+
override = opts[:engine] || PWN::Env.dig(:ai, :reflect_engine)
|
|
59
|
+
engine = (override || PWN::Env[:ai][:active]).to_s.downcase.to_sym
|
|
49
60
|
valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :agent }.map(&:downcase)
|
|
50
61
|
raise "ERROR: Unsupported AI engine. Supported engines are: #{valid_ai_engines}" unless valid_ai_engines.include?(engine)
|
|
51
62
|
|
|
52
63
|
warn "AI Reflection is enabled. Ensure #{engine} has been authorized for use and/or requests are sanitized properly." unless suppress_pii_warning
|
|
53
|
-
response =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
response = with_engine(engine: override) do
|
|
65
|
+
PWN::AI::Agent::Loop.run(
|
|
66
|
+
request: request.chomp,
|
|
67
|
+
system_role_content: system_role_content,
|
|
68
|
+
enabled_toolsets: [],
|
|
69
|
+
spinner: spinner
|
|
70
|
+
)
|
|
71
|
+
end
|
|
59
72
|
end
|
|
60
73
|
|
|
61
74
|
response
|
|
@@ -63,6 +76,23 @@ module PWN
|
|
|
63
76
|
raise e
|
|
64
77
|
end
|
|
65
78
|
|
|
79
|
+
# Temporarily override PWN::Env[:ai][:active] so Loop.run routes to
|
|
80
|
+
# the teacher engine, restoring afterwards even on raise. No-op when
|
|
81
|
+
# engine is nil/blank or PWN::Env[:ai] is frozen.
|
|
82
|
+
private_class_method def self.with_engine(opts = {})
|
|
83
|
+
engine = opts[:engine].to_s
|
|
84
|
+
ai = defined?(PWN::Env) && PWN::Env.is_a?(Hash) ? PWN::Env[:ai] : nil
|
|
85
|
+
return yield if engine.empty? || !ai.is_a?(Hash) || ai.frozen?
|
|
86
|
+
|
|
87
|
+
prev = ai[:active]
|
|
88
|
+
ai[:active] = engine
|
|
89
|
+
begin
|
|
90
|
+
yield
|
|
91
|
+
ensure
|
|
92
|
+
ai[:active] = prev
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
66
96
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
67
97
|
|
|
68
98
|
public_class_method def self.authors
|
|
@@ -78,10 +108,14 @@ module PWN
|
|
|
78
108
|
#{self}.on(
|
|
79
109
|
request: 'required - String - What you want the AI to reflect on',
|
|
80
110
|
system_role_content: 'optional - context to set up the model behavior for reflection',
|
|
111
|
+
engine: 'optional - override engine (Symbol) for teacher-student reflection; defaults to PWN::Env[:ai][:reflect_engine]',
|
|
81
112
|
spinner: 'optional - Boolean - Display spinner during operation (default: false)',
|
|
82
113
|
suppress_pii_warning: 'optional - Boolean - Suppress PII Warnings (default: false)'
|
|
83
114
|
)
|
|
84
115
|
|
|
116
|
+
Teacher-student config:
|
|
117
|
+
PWN::Env[:ai][:reflect_engine] = :anthropic # execute on :active, critique on :anthropic
|
|
118
|
+
|
|
85
119
|
#{self}.authors
|
|
86
120
|
"
|
|
87
121
|
end
|
|
@@ -18,6 +18,16 @@ module PWN
|
|
|
18
18
|
# agent/tools/*.rb (require registry, call .register at top level)
|
|
19
19
|
# ^
|
|
20
20
|
# agent/loop.rb (calls Registry.discover then .definitions)
|
|
21
|
+
#
|
|
22
|
+
# DYNAMIC TOOL-SET SLIMMING (local-model scaffolding)
|
|
23
|
+
# ---------------------------------------------------
|
|
24
|
+
# Shipping all ~47 tool schemas on every call overwhelms a 35B local
|
|
25
|
+
# model — it mis-routes (extro_rf_tune for a git question) because the
|
|
26
|
+
# choice space is huge. When PWN::Env[:ai][:agent][:tool_router] is
|
|
27
|
+
# truthy AND definitions(relevance: request) is passed, the pool is
|
|
28
|
+
# reduced to CORE_TOOLS + the top-K keyword-ranked matches. Routing
|
|
29
|
+
# accuracy is fed back into Metrics under name:'tool_router' so the
|
|
30
|
+
# router itself becomes a learned component.
|
|
21
31
|
module Registry
|
|
22
32
|
Entry = Struct.new(
|
|
23
33
|
:name, # String - tool name exposed to the model
|
|
@@ -29,6 +39,9 @@ module PWN
|
|
|
29
39
|
keyword_init: true
|
|
30
40
|
)
|
|
31
41
|
|
|
42
|
+
CORE_TOOLS = %w[shell pwn_eval memory_remember memory_recall
|
|
43
|
+
mistakes_record mistakes_resolve learning_note_outcome].freeze
|
|
44
|
+
|
|
32
45
|
@entries = {}
|
|
33
46
|
@discovered = false
|
|
34
47
|
|
|
@@ -84,15 +97,51 @@ module PWN
|
|
|
84
97
|
|
|
85
98
|
# Supported Method Parameters::
|
|
86
99
|
# tools = PWN::AI::Agent::Registry.definitions(
|
|
87
|
-
# enabled: 'optional - Array of toolset names to include; nil = all whose check passes'
|
|
100
|
+
# enabled: 'optional - Array of toolset names to include; nil = all whose check passes',
|
|
101
|
+
# relevance: 'optional - user request; when set AND :tool_router is enabled, slim to CORE + top-K keyword matches',
|
|
102
|
+
# top_k: 'optional - keyword-ranked tools to keep beyond CORE (default 10)'
|
|
88
103
|
# )
|
|
89
104
|
|
|
90
105
|
public_class_method def self.definitions(opts = {})
|
|
91
106
|
enabled = opts[:enabled]
|
|
92
107
|
enabled = enabled.map(&:to_s) if enabled
|
|
93
|
-
@entries.values
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
pool = @entries.values.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
|
|
109
|
+
|
|
110
|
+
if opts[:relevance] && router_enabled?
|
|
111
|
+
keep = rank(query: opts[:relevance], entries: pool).first(opts[:top_k] || 10).map(&:name)
|
|
112
|
+
names = (CORE_TOOLS + keep).uniq
|
|
113
|
+
pool = pool.select { |e| names.include?(e.name) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
pool.map { |e| { type: 'function', function: e.schema } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Supported Method Parameters::
|
|
120
|
+
# ranked = PWN::AI::Agent::Registry.rank(
|
|
121
|
+
# query: 'required - user request text',
|
|
122
|
+
# entries: 'optional - Entry pool to rank (default .all)'
|
|
123
|
+
# )
|
|
124
|
+
#
|
|
125
|
+
# Lightweight keyword TF router: scores each tool by the number of
|
|
126
|
+
# query tokens (≥ 3 chars, downcased) appearing in its name /
|
|
127
|
+
# description / property names. Zero-score entries drop off; ties
|
|
128
|
+
# break on historical Metrics success_rate so the router learns.
|
|
129
|
+
|
|
130
|
+
public_class_method def self.rank(opts = {})
|
|
131
|
+
query = opts[:query].to_s.downcase
|
|
132
|
+
entries = opts[:entries] || all
|
|
133
|
+
return entries if query.strip.empty?
|
|
134
|
+
|
|
135
|
+
tokens = query.scan(/[a-z0-9_]{3,}/).uniq
|
|
136
|
+
rates = metrics_rates
|
|
137
|
+
scored = entries.map do |e|
|
|
138
|
+
hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
|
|
139
|
+
score = tokens.count { |t| hay.include?(t) }
|
|
140
|
+
[e, score, rates[e.name] || 0.0]
|
|
141
|
+
end
|
|
142
|
+
scored.reject { |_, s, _| s.zero? }
|
|
143
|
+
.sort_by { |_, s, r| [-s, -r] }
|
|
144
|
+
.map(&:first)
|
|
96
145
|
end
|
|
97
146
|
|
|
98
147
|
# Supported Method Parameters::
|
|
@@ -123,6 +172,20 @@ module PWN
|
|
|
123
172
|
false
|
|
124
173
|
end
|
|
125
174
|
|
|
175
|
+
private_class_method def self.router_enabled?
|
|
176
|
+
defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env.dig(:ai, :agent, :tool_router)
|
|
177
|
+
rescue StandardError
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private_class_method def self.metrics_rates
|
|
182
|
+
return {} unless defined?(Metrics)
|
|
183
|
+
|
|
184
|
+
Metrics.summary(limit: 200).to_h { |r| [r[:name], r[:success_rate]] }
|
|
185
|
+
rescue StandardError
|
|
186
|
+
{}
|
|
187
|
+
end
|
|
188
|
+
|
|
126
189
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
127
190
|
|
|
128
191
|
public_class_method def self.authors
|
|
@@ -136,6 +199,8 @@ module PWN
|
|
|
136
199
|
USAGE:
|
|
137
200
|
PWN::AI::Agent::Registry.discover
|
|
138
201
|
PWN::AI::Agent::Registry.definitions(enabled: %w[terminal pwn])
|
|
202
|
+
PWN::AI::Agent::Registry.definitions(relevance: 'nmap sweep 10/8') # slim (needs :tool_router)
|
|
203
|
+
PWN::AI::Agent::Registry.rank(query: 'run a shell command')
|
|
139
204
|
PWN::AI::Agent::Registry.lookup(name: 'shell') # => Entry
|
|
140
205
|
PWN::AI::Agent::Registry.toolsets # => ["memory","pwn","skills","terminal"]
|
|
141
206
|
PWN::AI::Agent::Registry.register(name:, toolset:, schema:, handler:)
|
data/lib/pwn/ai/ollama.rb
CHANGED
|
@@ -143,11 +143,25 @@ module PWN
|
|
|
143
143
|
# spinner: 'optional - display spinner (default false)'
|
|
144
144
|
# )
|
|
145
145
|
#
|
|
146
|
-
# Returns
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
146
|
+
# Returns a Hash with :choices / :assistant_message intact (including
|
|
147
|
+
# :message[:tool_calls]) — used by PWN::AI::Agent::Loop.
|
|
148
|
+
#
|
|
149
|
+
# LOCAL-MODEL SCAFFOLDING
|
|
150
|
+
# -----------------------
|
|
151
|
+
# This hits Ollama's NATIVE /api/chat (not the OpenAI-compat shim) so
|
|
152
|
+
# the following actually take effect:
|
|
153
|
+
# options.num_ctx - Ollama defaults to 2048; the pwn-ai system
|
|
154
|
+
# prompt alone blows that. Defaults here to
|
|
155
|
+
# PWN::Env[:ai][:ollama][:num_ctx] || 32768.
|
|
156
|
+
# options.temperature - forced to 0.1 on tool-bearing turns for
|
|
157
|
+
# deterministic tool selection; engine[:temp]
|
|
158
|
+
# (creative) on the final text-only turn.
|
|
159
|
+
# format: 'json' - constrains the sampler to valid JSON when
|
|
160
|
+
# tools are present so Dispatch never sees
|
|
161
|
+
# malformed function.arguments.
|
|
162
|
+
# keep_alive: '30m' - avoids reload latency between iterations.
|
|
163
|
+
# tool_calls come back with function.arguments as a Hash (not a JSON
|
|
164
|
+
# string), which PWN::AI::Agent::Dispatch.parse_args handles.
|
|
151
165
|
|
|
152
166
|
public_class_method def self.chat_with_tools(opts = {})
|
|
153
167
|
engine = PWN::Env[:ai][:ollama]
|
|
@@ -160,18 +174,30 @@ module PWN
|
|
|
160
174
|
temp = opts[:temp].to_f
|
|
161
175
|
temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
|
|
162
176
|
|
|
177
|
+
tools_present = opts[:tools] && !opts[:tools].empty?
|
|
178
|
+
tool_temp = (engine[:tool_temp] || 0.1).to_f
|
|
179
|
+
num_ctx = (engine[:num_ctx] || 32_768).to_i
|
|
180
|
+
keep_alive = engine[:keep_alive] || '30m'
|
|
181
|
+
|
|
163
182
|
http_body = {
|
|
164
183
|
model: model,
|
|
165
184
|
messages: messages,
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
stream: false,
|
|
186
|
+
keep_alive: keep_alive,
|
|
187
|
+
options: {
|
|
188
|
+
num_ctx: num_ctx,
|
|
189
|
+
temperature: tools_present ? tool_temp : temp
|
|
190
|
+
}
|
|
168
191
|
}
|
|
169
|
-
|
|
192
|
+
if tools_present
|
|
193
|
+
http_body[:tools] = opts[:tools]
|
|
194
|
+
http_body[:format] = engine[:format] || 'json'
|
|
195
|
+
end
|
|
170
196
|
http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
|
|
171
197
|
|
|
172
198
|
response = ollama_rest_call(
|
|
173
199
|
http_method: :post,
|
|
174
|
-
rest_call: 'ollama/
|
|
200
|
+
rest_call: 'ollama/api/chat',
|
|
175
201
|
http_body: http_body,
|
|
176
202
|
timeout: opts[:timeout],
|
|
177
203
|
spinner: opts[:spinner]
|
|
@@ -179,7 +205,10 @@ module PWN
|
|
|
179
205
|
return nil if response.nil?
|
|
180
206
|
|
|
181
207
|
json_resp = JSON.parse(response, symbolize_names: true)
|
|
182
|
-
|
|
208
|
+
# Normalise native /api/chat shape to what Loop.normalize_llm expects.
|
|
209
|
+
msg = json_resp[:message] || json_resp.dig(:choices, 0, :message)
|
|
210
|
+
json_resp[:choices] = [{ message: msg }] if msg && !json_resp.key?(:choices)
|
|
211
|
+
json_resp[:assistant_message] = msg
|
|
183
212
|
json_resp
|
|
184
213
|
rescue StandardError => e
|
|
185
214
|
raise e
|
data/lib/pwn/config.rb
CHANGED
|
@@ -65,8 +65,13 @@ module PWN
|
|
|
65
65
|
base_uri: 'required - Base URI for Open WebUI - e.g. https://ollama.local',
|
|
66
66
|
key: 'required - Open WebUI API Key Under Settings >> Account >> JWT Token',
|
|
67
67
|
model: 'required - Ollama model to use',
|
|
68
|
+
embed_model: 'optional - embedding model for PWN::MemoryIndex (default nomic-embed-text)',
|
|
68
69
|
system_role_content: 'You are an ethically hacking Ollama agent.',
|
|
69
70
|
temp: 'optional - Ollama temperature',
|
|
71
|
+
num_ctx: 32_768,
|
|
72
|
+
keep_alive: '30m',
|
|
73
|
+
# tighten each PromptBuilder block for the local model (nil = engine defaults)
|
|
74
|
+
prompt_budget: { memory: 6, metrics: 3, mistakes: 3, learning: 2, extro: false },
|
|
70
75
|
max_prompt_length: 32_000
|
|
71
76
|
},
|
|
72
77
|
anthropic: {
|
|
@@ -86,6 +91,8 @@ module PWN
|
|
|
86
91
|
temp: 'optional - Gemini temperature',
|
|
87
92
|
max_prompt_length: 1_000_000
|
|
88
93
|
},
|
|
94
|
+
# teacher-student reflection: execute on :active, write durable lessons via this engine (nil = same as :active)
|
|
95
|
+
reflect_engine: nil,
|
|
89
96
|
agent: {
|
|
90
97
|
native_tools: true,
|
|
91
98
|
max_iters: 25,
|
|
@@ -95,6 +102,10 @@ module PWN
|
|
|
95
102
|
auto_introspect: true,
|
|
96
103
|
# also run PWN::AI::Agent::Extrospection.auto_extrospect from auto_introspect
|
|
97
104
|
auto_extrospect: false,
|
|
105
|
+
# engine-agnostic scaffolding (defaults tuned for local models)
|
|
106
|
+
plan_first: nil, # nil = auto (true when :active == :ollama)
|
|
107
|
+
tool_router: false, # slim Registry.definitions to CORE + top-K relevant tools
|
|
108
|
+
escalation_persona: nil, # Swarm persona name for frontier corrective hints when a local model is stuck
|
|
98
109
|
toolsets: nil
|
|
99
110
|
# multi-agent personas : ~/.pwn/agents.yml (see PWN::AI::Agent::Swarm.help)
|
|
100
111
|
# swarm bus : ~/.pwn/swarm/<swarm_id>/bus.jsonl
|