pwn 0.5.684 → 0.5.686
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 +6 -0
- data/documentation/Agent-Tool-Registry.md +1 -3
- data/documentation/Configuration.md +0 -2
- data/documentation/General-PWN-Usage.md +1 -1
- data/documentation/Home.md +2 -2
- data/documentation/How-PWN-Works.md +2 -2
- data/documentation/Installation.md +18 -2
- data/documentation/Persistence.md +1 -1
- data/documentation/Session-Workflow.md +8 -28
- data/documentation/Skills-Memory-Learning.md +20 -0
- data/documentation/Troubleshooting.md +1 -1
- data/documentation/Why-PWN.md +1 -1
- data/documentation/diagrams/dot/memory-skills-detailed.dot +1 -1
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +1 -1
- data/documentation/diagrams/dot/persistence-filesystem.dot +1 -1
- data/documentation/diagrams/dot/sessions-cron-automation.dot +1 -1
- data/documentation/diagrams/memory-skills-detailed.svg +85 -84
- data/documentation/diagrams/overall-pwn-architecture.svg +15 -15
- data/documentation/diagrams/persistence-filesystem.svg +1 -1
- data/documentation/diagrams/sessions-cron-automation.svg +7 -6
- data/documentation/pwn-REPL.md +1 -1
- data/documentation/pwn-ai-Agent.md +4 -7
- data/etc/default_skills/bug-bounty-hunting/SKILL.md +70 -0
- data/etc/default_skills/deep-exploitation/SKILL.md +74 -0
- data/etc/default_skills/hardware-and-firmware-testing/SKILL.md +83 -0
- data/etc/default_skills/osint/SKILL.md +147 -0
- data/etc/default_skills/penetration-testing/SKILL.md +81 -0
- data/etc/default_skills/red-teaming/SKILL.md +70 -0
- data/etc/default_skills/reverse-engineering-binaries/SKILL.md +70 -0
- data/etc/default_skills/sast-code-scans/SKILL.md +66 -0
- data/etc/default_skills/social-engineering/SKILL.md +67 -0
- data/etc/default_skills/vulnerability-research-fundamentals/SKILL.md +74 -0
- data/etc/default_skills/web-application-penetration-testing/SKILL.md +68 -0
- data/lib/pwn/ai/agent/loop.rb +79 -41
- data/lib/pwn/ai/agent/mistakes.rb +6 -6
- data/lib/pwn/ai/agent/prompt_builder.rb +3 -4
- data/lib/pwn/ai/agent/tool_guard.rb +1 -46
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +0 -10
- data/lib/pwn/ai/agent/tools/shell.rb +0 -6
- data/lib/pwn/ai/anthropic.rb +0 -118
- data/lib/pwn/ai/gemini.rb +0 -15
- data/lib/pwn/ai/grok.rb +0 -158
- data/lib/pwn/ai/ollama.rb +0 -12
- data/lib/pwn/ai/open_ai.rb +0 -110
- data/lib/pwn/ai/open_web_ui.rb +0 -12
- data/lib/pwn/ai.rb +0 -86
- data/lib/pwn/config.rb +62 -1
- data/lib/pwn/migrate.rb +2 -0
- data/lib/pwn/plugins/log.rb +302 -0
- data/lib/pwn/plugins/repl.rb +28 -8
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/config_spec.rb +37 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +52 -12
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +7 -0
- data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +5 -8
- data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -18
- data/spec/lib/pwn/ai/anthropic_spec.rb +2 -2
- data/spec/lib/pwn/ai/gemini_spec.rb +2 -2
- data/spec/lib/pwn/ai/grok_spec.rb +2 -2
- data/spec/lib/pwn/ai/ollama_spec.rb +2 -7
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -23
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +2 -7
- data/spec/lib/pwn/ai_spec.rb +4 -21
- data/spec/lib/pwn/plugins/log_spec.rb +139 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +31 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +4 -3
- data/third_party/pwn_rdoc.jsonl +29 -24
- metadata +12 -2
- data/documentation/PWN_Contributors_and_Users.png +0 -0
data/lib/pwn/migrate.rb
CHANGED
|
@@ -210,6 +210,7 @@ module PWN
|
|
|
210
210
|
{ migrated: 0 }
|
|
211
211
|
end
|
|
212
212
|
io.puts " · migrate_legacy_skills → #{r[:migrated]} converted" if r[:migrated].to_i.positive?
|
|
213
|
+
PWN::Config.install_default_skills if defined?(PWN::Config)
|
|
213
214
|
PWN::Cron.install_defaults if defined?(PWN::Cron)
|
|
214
215
|
}
|
|
215
216
|
}.freeze
|
|
@@ -512,6 +513,7 @@ module PWN
|
|
|
512
513
|
strip_bad_jsonl!(rel: f[:rel])
|
|
513
514
|
when :skills
|
|
514
515
|
PWN::Config.migrate_legacy_skills
|
|
516
|
+
PWN::Config.install_default_skills if PWN::Config.respond_to?(:install_default_skills)
|
|
515
517
|
when :seed
|
|
516
518
|
FileUtils.mkdir_p(File.join(PWN_ROOT, f[:rel])) if f[:kind] == :dir
|
|
517
519
|
PWN::Cron.install_defaults if f[:rel].start_with?('cron/') && defined?(PWN::Cron)
|
data/lib/pwn/plugins/log.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'logger'
|
|
5
6
|
require 'securerandom'
|
|
@@ -8,6 +9,35 @@ module PWN
|
|
|
8
9
|
module Plugins
|
|
9
10
|
# This plugin is used to instantiate a PWN logger with a custom message format
|
|
10
11
|
module Log
|
|
12
|
+
TRACE_SKIP_METHODS = %i[
|
|
13
|
+
authors help to_s inspect class object_id hash eql? == equal? !
|
|
14
|
+
method public_send send __send__ instance_eval class_eval
|
|
15
|
+
public_class_method private_class_method
|
|
16
|
+
].freeze
|
|
17
|
+
TRACE_SKIP_PREFIXES = %w[
|
|
18
|
+
PWN::Plugins::Log
|
|
19
|
+
PWN::Plugins::REPL
|
|
20
|
+
PWN::Banner
|
|
21
|
+
].freeze
|
|
22
|
+
DEFAULT_TRACE_PREFIXES = %w[
|
|
23
|
+
PWN::AI
|
|
24
|
+
PWN::Memory
|
|
25
|
+
PWN::Sessions
|
|
26
|
+
PWN::Config
|
|
27
|
+
PWN::Cron
|
|
28
|
+
PWN::Plugins
|
|
29
|
+
].freeze
|
|
30
|
+
SECRET_KEY_RX = /password|passwd|secret|token|api[_-]?key|authorization|bearer|cookie|session[_-]?id|private[_-]?key|decryptor|credential|ssh[_-]?key|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|vault|csrf/i
|
|
31
|
+
SECRET_VALUE_RX = %r{
|
|
32
|
+
-----BEGIN\ [A-Z ]*PRIVATE\ KEY----- |
|
|
33
|
+
Bearer\s+[A-Za-z0-9\-._~+/]+=* |
|
|
34
|
+
\beyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+ |
|
|
35
|
+
\b(?:sk|rk|pk|xai|xox[baprs]|ghp|gho|ghu|ghs|ghr|glpat|AKIA|ASIA|ya29)[-_][A-Za-z0-9\-_]{16,} |
|
|
36
|
+
\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*\S+
|
|
37
|
+
}ix
|
|
38
|
+
DEBUG_VALUE_MAX = 240
|
|
39
|
+
DEBUG_ARGS_MAX = 1_800
|
|
40
|
+
|
|
11
41
|
# Supported Method Parameters::
|
|
12
42
|
# PWN::Log.create(
|
|
13
43
|
# )
|
|
@@ -99,6 +129,271 @@ module PWN
|
|
|
99
129
|
raise e
|
|
100
130
|
end
|
|
101
131
|
|
|
132
|
+
public_class_method def self.debug_enabled?(opts = {})
|
|
133
|
+
return @debug_enabled == true if opts.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
@debug_enabled == true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
public_class_method def self.debug_log_path(opts = {})
|
|
139
|
+
return @debug_path if opts.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
@debug_path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Open /tmp/pwn-ai-DEBUG-TIMESTAMP.log, tee the same lines to the TUI
|
|
145
|
+
# (opts[:tee]), and TracePoint PWN modules that process a pwn-ai request.
|
|
146
|
+
DEBUG_LOG_MAX = 1_024_000
|
|
147
|
+
|
|
148
|
+
public_class_method def self.start_debug(opts = {})
|
|
149
|
+
return @debug_path if debug_enabled? && opts.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
ts = Time.now.strftime('%Y%m%d-%H%M%S')
|
|
152
|
+
path = opts[:path].to_s
|
|
153
|
+
path = "/tmp/pwn-ai-DEBUG-#{ts}.1.log" if path.empty?
|
|
154
|
+
stem, idx = debug_path_parts(path: path)
|
|
155
|
+
path = "#{stem}.#{idx}.log"
|
|
156
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
157
|
+
io = File.open(path, 'a')
|
|
158
|
+
io.sync = true
|
|
159
|
+
@debug_file = io
|
|
160
|
+
@debug_path = path
|
|
161
|
+
@debug_stem = stem
|
|
162
|
+
@debug_index = idx
|
|
163
|
+
@debug_tee = opts[:tee]
|
|
164
|
+
@debug_tui_quiet = false
|
|
165
|
+
@debug_enabled = true
|
|
166
|
+
start_trace!(prefixes: opts[:prefixes]) unless opts[:trace] == false
|
|
167
|
+
progress(msg: "debug session start path=#{path}", which_self: self)
|
|
168
|
+
path
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
public_class_method def self.stop_debug(opts = {})
|
|
172
|
+
reason = opts[:reason].to_s if opts.is_a?(Hash)
|
|
173
|
+
if debug_enabled?
|
|
174
|
+
tail = reason.to_s.empty? ? 'debug session stop' : "debug session stop reason=#{reason}"
|
|
175
|
+
progress(msg: tail, which_self: self)
|
|
176
|
+
end
|
|
177
|
+
stop_trace!
|
|
178
|
+
begin
|
|
179
|
+
@debug_file&.close unless @debug_file.nil? || @debug_file.closed?
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
path = @debug_path
|
|
184
|
+
@debug_file = nil
|
|
185
|
+
@debug_path = nil
|
|
186
|
+
@debug_stem = nil
|
|
187
|
+
@debug_index = nil
|
|
188
|
+
@debug_tee = nil
|
|
189
|
+
@debug_tui_quiet = false
|
|
190
|
+
@debug_enabled = false
|
|
191
|
+
path
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private_class_method def self.debug_path_parts(opts = {})
|
|
195
|
+
path = opts[:path].to_s
|
|
196
|
+
return [Regexp.last_match(1), Regexp.last_match(2).to_i] if path =~ /\A(.+)\.(\d+)\.log\z/
|
|
197
|
+
|
|
198
|
+
[path.sub(/\.log\z/, ''), 1]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private_class_method def self.roll_debug_log!(opts = {})
|
|
202
|
+
return if opts[:skip]
|
|
203
|
+
return if @debug_file.nil? || @debug_file.closed?
|
|
204
|
+
return if @debug_file.size < DEBUG_LOG_MAX
|
|
205
|
+
|
|
206
|
+
@debug_file.close
|
|
207
|
+
@debug_index = @debug_index.to_i + 1
|
|
208
|
+
path = "#{@debug_stem}.#{@debug_index}.log"
|
|
209
|
+
io = File.open(path, 'a')
|
|
210
|
+
io.sync = true
|
|
211
|
+
@debug_file = io
|
|
212
|
+
@debug_path = path
|
|
213
|
+
path
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
public_class_method def self.quiet_tui!(opts = {})
|
|
217
|
+
return if opts[:skip]
|
|
218
|
+
|
|
219
|
+
@debug_tui_quiet = true
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
public_class_method def self.loud_tui!(opts = {})
|
|
223
|
+
return if opts[:skip]
|
|
224
|
+
|
|
225
|
+
@debug_tui_quiet = false
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# One progress line to the debug file and the TUI tee (same payload).
|
|
229
|
+
public_class_method def self.progress(opts = {})
|
|
230
|
+
return false unless debug_enabled?
|
|
231
|
+
return false if Thread.current[:pwn_log_progress]
|
|
232
|
+
|
|
233
|
+
Thread.current[:pwn_log_progress] = true
|
|
234
|
+
msg = sanitize_debug_text(text: opts[:msg].to_s)
|
|
235
|
+
which = opts[:which_self] || self
|
|
236
|
+
line = format_progress(msg: msg, which_self: which)
|
|
237
|
+
begin
|
|
238
|
+
@debug_file&.puts(line)
|
|
239
|
+
@debug_file&.flush
|
|
240
|
+
roll_debug_log!
|
|
241
|
+
rescue StandardError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
tee = opts.key?(:tee) ? opts[:tee] : @debug_tee
|
|
245
|
+
if !@debug_tui_quiet && tee.respond_to?(:puts)
|
|
246
|
+
begin
|
|
247
|
+
tee.puts(color_progress(line: line))
|
|
248
|
+
rescue StandardError
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
true
|
|
253
|
+
ensure
|
|
254
|
+
Thread.current[:pwn_log_progress] = false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private_class_method def self.format_progress(opts = {})
|
|
258
|
+
msg = opts[:msg].to_s.tr("\n", ' ')[0, 4_000]
|
|
259
|
+
which = opts[:which_self]
|
|
260
|
+
who = if which.is_a?(Module)
|
|
261
|
+
(which.name || which.to_s).to_s
|
|
262
|
+
else
|
|
263
|
+
which.to_s
|
|
264
|
+
end
|
|
265
|
+
ts = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L%z')
|
|
266
|
+
bits = ["[DEBUG #{ts}]"]
|
|
267
|
+
bits << who unless who.empty?
|
|
268
|
+
bits << msg unless msg.empty?
|
|
269
|
+
sanitize_debug_text(text: bits.join(' '))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private_class_method def self.color_progress(opts = {})
|
|
273
|
+
line = opts[:line].to_s
|
|
274
|
+
"\001\e[35m\002#{line}\001\e[0m\002"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private_class_method def self.trace_module_name(opts = {})
|
|
278
|
+
tp = opts[:tp]
|
|
279
|
+
return '' unless tp
|
|
280
|
+
|
|
281
|
+
mod = tp.self.is_a?(Module) ? tp.self : tp.defined_class
|
|
282
|
+
name = if mod.respond_to?(:name) && mod.name
|
|
283
|
+
mod.name
|
|
284
|
+
else
|
|
285
|
+
mod.to_s
|
|
286
|
+
end
|
|
287
|
+
name.to_s.sub(/\A#<Class:(.+?)>\z/, '\1')
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private_class_method def self.secret_key?(opts = {})
|
|
291
|
+
opts[:key].to_s.match?(SECRET_KEY_RX)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private_class_method def self.sanitize_debug_text(opts = {})
|
|
295
|
+
text = opts[:text].to_s
|
|
296
|
+
text = text.gsub(SECRET_VALUE_RX, '[REDACTED]')
|
|
297
|
+
text.gsub(/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/m, '[REDACTED]')
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private_class_method def self.inspect_debug(opts = {})
|
|
301
|
+
val = opts[:value]
|
|
302
|
+
key = opts[:key]
|
|
303
|
+
return '[REDACTED]' if secret_key?(key: key)
|
|
304
|
+
return format_debug_hash(hash: val) if val.is_a?(Hash)
|
|
305
|
+
return '[REDACTED]' if val.is_a?(String) && val.match?(SECRET_VALUE_RX)
|
|
306
|
+
|
|
307
|
+
raw = sanitize_debug_text(text: val.inspect)
|
|
308
|
+
raw = "#{raw[0, DEBUG_VALUE_MAX]}…" if raw.length > DEBUG_VALUE_MAX
|
|
309
|
+
raw
|
|
310
|
+
rescue StandardError
|
|
311
|
+
"#<#{val.class}>"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private_class_method def self.format_debug_hash(opts = {})
|
|
315
|
+
hash = opts[:hash]
|
|
316
|
+
return '{}' unless hash.is_a?(Hash)
|
|
317
|
+
|
|
318
|
+
parts = hash.map do |k, v|
|
|
319
|
+
"#{k}: #{inspect_debug(value: v, key: k)}"
|
|
320
|
+
end
|
|
321
|
+
"{#{parts.join(', ')}}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private_class_method def self.trace_arguments(opts = {})
|
|
325
|
+
tp = opts[:tp]
|
|
326
|
+
return '' unless tp
|
|
327
|
+
|
|
328
|
+
bind = tp.binding
|
|
329
|
+
parts = []
|
|
330
|
+
Array(tp.parameters).each do |kind, name|
|
|
331
|
+
next if name.nil?
|
|
332
|
+
next if %i[rest keyrest block].include?(kind)
|
|
333
|
+
next unless bind.local_variable_defined?(name)
|
|
334
|
+
|
|
335
|
+
val = bind.local_variable_get(name)
|
|
336
|
+
if name == :opts && val.is_a?(Hash)
|
|
337
|
+
rendered = format_debug_hash(hash: val)
|
|
338
|
+
return rendered == '{}' ? '' : rendered
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
parts << "#{name}=#{inspect_debug(value: val, key: name)}"
|
|
342
|
+
end
|
|
343
|
+
out = parts.join(', ')
|
|
344
|
+
out = "#{out[0, DEBUG_ARGS_MAX]}…" if out.length > DEBUG_ARGS_MAX
|
|
345
|
+
out
|
|
346
|
+
rescue StandardError
|
|
347
|
+
''
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
private_class_method def self.format_trace_call(opts = {})
|
|
351
|
+
tp = opts[:tp]
|
|
352
|
+
klass = trace_module_name(tp: tp)
|
|
353
|
+
meth = tp.method_id
|
|
354
|
+
args = trace_arguments(tp: tp)
|
|
355
|
+
args.to_s.empty? ? "#{klass}.#{meth}" : "#{klass}.#{meth}(#{args})"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
private_class_method def self.traceable?(opts = {})
|
|
359
|
+
tp = opts[:tp]
|
|
360
|
+
prefixes = opts[:prefixes] || DEFAULT_TRACE_PREFIXES
|
|
361
|
+
name = trace_module_name(tp: tp)
|
|
362
|
+
return false if name.empty?
|
|
363
|
+
return false if TRACE_SKIP_PREFIXES.any? { |p| name.start_with?(p) }
|
|
364
|
+
return false unless prefixes.any? { |p| name.start_with?(p) }
|
|
365
|
+
return false if TRACE_SKIP_METHODS.include?(tp.method_id)
|
|
366
|
+
|
|
367
|
+
true
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
public_class_method def self.start_trace!(opts = {})
|
|
371
|
+
return @debug_tp if @debug_tp && opts.is_a?(Hash)
|
|
372
|
+
|
|
373
|
+
prefixes = Array(opts[:prefixes] || DEFAULT_TRACE_PREFIXES).map(&:to_s)
|
|
374
|
+
prefixes = DEFAULT_TRACE_PREFIXES if prefixes.empty?
|
|
375
|
+
@debug_tp = TracePoint.new(:call) do |tp|
|
|
376
|
+
next unless debug_enabled?
|
|
377
|
+
next unless traceable?(tp: tp, prefixes: prefixes)
|
|
378
|
+
|
|
379
|
+
progress(
|
|
380
|
+
msg: format_trace_call(tp: tp),
|
|
381
|
+
which_self: ''
|
|
382
|
+
)
|
|
383
|
+
rescue StandardError
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
@debug_tp.enable
|
|
387
|
+
@debug_tp
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
public_class_method def self.stop_trace!(opts = {})
|
|
391
|
+
return if opts[:skip]
|
|
392
|
+
|
|
393
|
+
@debug_tp&.disable
|
|
394
|
+
@debug_tp = nil
|
|
395
|
+
end
|
|
396
|
+
|
|
102
397
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
103
398
|
|
|
104
399
|
public_class_method def self.authors
|
|
@@ -116,6 +411,13 @@ module PWN
|
|
|
116
411
|
msg: 'required - message to log',
|
|
117
412
|
which_self: 'required - pass in self object from module calling #{self}'
|
|
118
413
|
)
|
|
414
|
+
|
|
415
|
+
path = #{self}.start_debug(
|
|
416
|
+
tee: $stdout,
|
|
417
|
+
path: 'optional - defaults to /tmp/pwn-ai-DEBUG-TIMESTAMP.log'
|
|
418
|
+
)
|
|
419
|
+
#{self}.progress(msg: 'stage', which_self: self)
|
|
420
|
+
#{self}.stop_debug
|
|
119
421
|
"
|
|
120
422
|
end
|
|
121
423
|
end
|
data/lib/pwn/plugins/repl.rb
CHANGED
|
@@ -375,11 +375,10 @@ module PWN
|
|
|
375
375
|
max_context = PWN::Env[:ai][engine][:max_prompt_length].to_i
|
|
376
376
|
current_context_length = "#{PWN::Plugins::REPL.compact_context_tokens(tokens: used_tokens)}:" \
|
|
377
377
|
"#{PWN::Plugins::REPL.compact_context_tokens(tokens: max_context)}"
|
|
378
|
-
plan_usage_glyph = PWN::AI.plan_usage_glyph(engine: engine)
|
|
379
378
|
|
|
380
379
|
pname = "pwn.ai:#{engine}"
|
|
381
|
-
pname = "pwn.ai:#{engine}/#{model}/#{current_context_length}
|
|
382
|
-
pname = "pwn.ai:#{engine}/#{model}/#{current_context_length}
|
|
380
|
+
pname = "pwn.ai:#{engine}/#{model}/#{current_context_length}" if model
|
|
381
|
+
pname = "pwn.ai:#{engine}/#{model}/#{current_context_length}.SPEAK" if pi.config.pwn_ai_speak
|
|
383
382
|
pi.config.prompt_name = pname
|
|
384
383
|
|
|
385
384
|
name = "\001\e[1m\002\001\e[33m\002#{pi.config.prompt_name}\001\e[0m\002"
|
|
@@ -1045,11 +1044,23 @@ module PWN
|
|
|
1045
1044
|
end
|
|
1046
1045
|
|
|
1047
1046
|
Pry::Commands.create_command 'toggle-pwn-ai-debug' do
|
|
1048
|
-
description '
|
|
1047
|
+
description 'Stream pwn-ai module progress to the TUI and /tmp/pwn-ai-DEBUG-TIMESTAMP.log'
|
|
1049
1048
|
|
|
1050
1049
|
def process
|
|
1051
1050
|
pi = pry_instance
|
|
1052
|
-
|
|
1051
|
+
if pi.config.pwn_ai_debug
|
|
1052
|
+
path = PWN::Plugins::Log.stop_debug
|
|
1053
|
+
pi.config.pwn_ai_debug = false
|
|
1054
|
+
if path
|
|
1055
|
+
output.puts "pwn-ai debug OFF (was #{path})"
|
|
1056
|
+
else
|
|
1057
|
+
output.puts 'pwn-ai debug OFF'
|
|
1058
|
+
end
|
|
1059
|
+
else
|
|
1060
|
+
path = PWN::Plugins::Log.start_debug(tee: output)
|
|
1061
|
+
pi.config.pwn_ai_debug = true
|
|
1062
|
+
output.puts "pwn-ai debug ON → #{path}"
|
|
1063
|
+
end
|
|
1053
1064
|
end
|
|
1054
1065
|
end
|
|
1055
1066
|
|
|
@@ -1071,7 +1082,7 @@ module PWN
|
|
|
1071
1082
|
pi.config.pwn_asm = false if pi.config.pwn_asm
|
|
1072
1083
|
pi.config.pwn_ai = false if pi.config.pwn_ai
|
|
1073
1084
|
pi.config.pwn_ai_agent = false if pi.config.pwn_ai_agent
|
|
1074
|
-
pi.config.pwn_ai_debug = false if pi.config.pwn_ai_debug
|
|
1085
|
+
# pi.config.pwn_ai_debug = false if pi.config.pwn_ai_debug
|
|
1075
1086
|
pi.config.pwn_ai_speak = false if pi.config.pwn_ai_speak
|
|
1076
1087
|
pi.config.completer = Pry::InputCompleter
|
|
1077
1088
|
# pi.config.pwn_ai_original_input ||= Pry.config.input.clone
|
|
@@ -1215,10 +1226,19 @@ module PWN
|
|
|
1215
1226
|
request: orig_request,
|
|
1216
1227
|
session_id: sess_id,
|
|
1217
1228
|
enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
|
|
1218
|
-
on_tool: on_tool
|
|
1229
|
+
on_tool: on_tool,
|
|
1230
|
+
debug: pi.config.pwn_ai_debug,
|
|
1231
|
+
debug_tee: $stdout
|
|
1219
1232
|
)
|
|
1233
|
+
$stdout.flush
|
|
1220
1234
|
puts "\n\001\e[32m\002#{final}\001\e[0m\002\n\n"
|
|
1221
|
-
|
|
1235
|
+
$stdout.flush
|
|
1236
|
+
if pi.config.pwn_ai_debug && sess_id && PWN.const_defined?(:Sessions)
|
|
1237
|
+
PWN::Plugins::Log.progress(
|
|
1238
|
+
msg: "session=#{sess_id}",
|
|
1239
|
+
which_self: PWN::Sessions
|
|
1240
|
+
)
|
|
1241
|
+
end
|
|
1222
1242
|
request.replace('nil')
|
|
1223
1243
|
next
|
|
1224
1244
|
rescue StandardError => e
|
data/lib/pwn/version.rb
CHANGED
|
@@ -65,4 +65,41 @@ RSpec.describe 'PWN::Config / etc/*.EXAMPLE hygiene', :aggregate_failures do
|
|
|
65
65
|
end
|
|
66
66
|
expect(PWN::Setup::PROFILES).to include(:core, :full)
|
|
67
67
|
end
|
|
68
|
+
|
|
69
|
+
it 'install_default_skills seeds the bundled offensive skills and does not overwrite edits' do
|
|
70
|
+
Dir.mktmpdir('pwn_default_skills') do |dir|
|
|
71
|
+
expect(PWN::Config).to respond_to(:install_default_skills)
|
|
72
|
+
expect(PWN::Config).to respond_to(:default_skill_names)
|
|
73
|
+
names = PWN::Config.default_skill_names
|
|
74
|
+
expect(names).to eq %w[
|
|
75
|
+
vulnerability-research-fundamentals
|
|
76
|
+
deep-exploitation
|
|
77
|
+
bug-bounty-hunting
|
|
78
|
+
sast-code-scans
|
|
79
|
+
reverse-engineering-binaries
|
|
80
|
+
penetration-testing
|
|
81
|
+
web-application-penetration-testing
|
|
82
|
+
red-teaming
|
|
83
|
+
hardware-and-firmware-testing
|
|
84
|
+
social-engineering
|
|
85
|
+
osint
|
|
86
|
+
]
|
|
87
|
+
seeded = PWN::Config.install_default_skills(pwn_skills_path: dir)
|
|
88
|
+
expect(seeded.map { |r| r[:name] }).to eq names
|
|
89
|
+
names.each do |name|
|
|
90
|
+
path = File.join(dir, name, 'SKILL.md')
|
|
91
|
+
expect(File.file?(path)).to eq(true), path
|
|
92
|
+
body = File.read(path)
|
|
93
|
+
expect(body).to start_with("---\n")
|
|
94
|
+
expect(body).to include("name: #{name}")
|
|
95
|
+
expect(body).not_to match(/hermes/i)
|
|
96
|
+
expect(body).to match(/## Methodolog/i)
|
|
97
|
+
end
|
|
98
|
+
marker = File.join(dir, 'bug-bounty-hunting', 'SKILL.md')
|
|
99
|
+
File.write(marker, "# user edited\n")
|
|
100
|
+
again = PWN::Config.install_default_skills(pwn_skills_path: dir)
|
|
101
|
+
expect(again).to eq([])
|
|
102
|
+
expect(File.read(marker)).to eq("# user edited\n")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
68
105
|
end
|
|
@@ -622,7 +622,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
622
622
|
end
|
|
623
623
|
end
|
|
624
624
|
|
|
625
|
-
describe 'intent routing (
|
|
625
|
+
describe 'intent routing (how-to + greeting + recall)' do
|
|
626
626
|
it 'classifies pure how-to vs live recon vs act' do
|
|
627
627
|
expect(described_class.request_intent(request: 'how to do a ping sweep of a subnet using hping3?')).to eq(:howto)
|
|
628
628
|
expect(described_class.request_intent(request: 'using hping3 what live hosts can you find in this subnet?')).to eq(:recon_act)
|
|
@@ -633,12 +633,42 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
633
633
|
expect(described_class).not_to respond_to :request_kind
|
|
634
634
|
end
|
|
635
635
|
|
|
636
|
-
it '
|
|
636
|
+
it 'does not invent a request type for world-knowledge; the full loop still accepts a text final' do
|
|
637
|
+
expect(described_class.world_knowledge?(request: 'what color is a passion fruit?')).to eq true
|
|
637
638
|
src = File.read(described_class.method(:run).source_location.first)
|
|
638
|
-
expect(src).not_to match(/
|
|
639
|
-
expect(src).not_to match(/
|
|
640
|
-
expect(src).
|
|
641
|
-
|
|
639
|
+
expect(src).not_to match(/request_kind/)
|
|
640
|
+
expect(src).not_to match(/world_knowledge\?.*?return answer_question/m)
|
|
641
|
+
expect(src).to match(/world_knowledge\?/)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
it 'full tool loop accepts a text final for world-knowledge with no host-work tools' do
|
|
645
|
+
expect(described_class.world_knowledge?(request: 'what color is a passion fruit?')).to eq true
|
|
646
|
+
expect(described_class.send(:request_need, request: 'what color is a passion fruit?')).to eq :none
|
|
647
|
+
expect(
|
|
648
|
+
described_class.send(
|
|
649
|
+
:request_unsatisfied?,
|
|
650
|
+
request: 'what color is a passion fruit?',
|
|
651
|
+
messages: [{ role: 'assistant', content: 'Purple when ripe.', tool_calls: [] }]
|
|
652
|
+
)
|
|
653
|
+
).to eq false
|
|
654
|
+
sess_tmp = Dir.mktmpdir('pwn-sess-fruit')
|
|
655
|
+
stub_const('PWN::Sessions::SESSIONS_DIR', sess_tmp)
|
|
656
|
+
sid = 'fruit_spec'
|
|
657
|
+
PWN::Sessions.create(id: sid, title: 'fruit')
|
|
658
|
+
allow(described_class).to receive(:should_auto_introspect?).and_return(false)
|
|
659
|
+
allow(described_class).to receive(:plan_first).and_raise('plan_first must not run on world-knowledge')
|
|
660
|
+
allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false) if defined?(PWN::AI::Agent::TaskSummarizer)
|
|
661
|
+
allow(described_class).to receive(:call_engine).and_return(
|
|
662
|
+
{ role: 'assistant', content: 'Purple when ripe.', tool_calls: [] }
|
|
663
|
+
)
|
|
664
|
+
out = described_class.run(
|
|
665
|
+
request: 'what color is a passion fruit?',
|
|
666
|
+
session_id: sid,
|
|
667
|
+
system_role_content: 'test system'
|
|
668
|
+
)
|
|
669
|
+
expect(out).to include('Purple when ripe.')
|
|
670
|
+
ensure
|
|
671
|
+
FileUtils.rm_rf(sess_tmp) if sess_tmp
|
|
642
672
|
end
|
|
643
673
|
|
|
644
674
|
it 'classifies pure prior-turn recall and vague memory cues as :recall' do
|
|
@@ -668,12 +698,14 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
668
698
|
expect(described_class.request_intent(request: 'how to do a ping sweep with hping3?')).to eq(:howto)
|
|
669
699
|
end
|
|
670
700
|
|
|
671
|
-
it '
|
|
672
|
-
expect(described_class
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
expect(
|
|
701
|
+
it 'does not refuse sweeps for missing scope language' do
|
|
702
|
+
expect(described_class).not_to respond_to(:recon_authorized?)
|
|
703
|
+
src = File.read(described_class.method(:run).source_location.first)
|
|
704
|
+
expect(src).not_to match(/AUTH_SCOPE_RX/)
|
|
705
|
+
expect(src).not_to match(/need scope language/)
|
|
706
|
+
expect(src).not_to match(/refuse the live scan/)
|
|
707
|
+
expect(src).not_to match(/pwn_recon_authorized/)
|
|
708
|
+
expect(src).not_to match(/in-scope authorization/)
|
|
677
709
|
end
|
|
678
710
|
|
|
679
711
|
it 'run short-circuits how-to without plan_first or tools' do
|
|
@@ -977,4 +1009,12 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
977
1009
|
expect(src).not_to include('return false if kind == :statement')
|
|
978
1010
|
expect(src).not_to include('return false if kind == :question && fails.zero?')
|
|
979
1011
|
end
|
|
1012
|
+
|
|
1013
|
+
it 'streams request-processing progress through PWN::Plugins::Log when debug is on' do
|
|
1014
|
+
src = File.read(described_class.method(:run).source_location.first)
|
|
1015
|
+
expect(src).to match(/PWN::Plugins::Log\.start_debug/)
|
|
1016
|
+
expect(src).to match(/PWN::Plugins::Log\.progress/)
|
|
1017
|
+
expect(src).to match(/quiet_debug_tui!/)
|
|
1018
|
+
expect(described_class).to respond_to(:debug_on?)
|
|
1019
|
+
end
|
|
980
1020
|
end # rubocop:enable Metrics/BlockLength
|
|
@@ -184,4 +184,11 @@ describe PWN::AI::Agent::Mistakes do
|
|
|
184
184
|
expect(ctx).to include('shell')
|
|
185
185
|
expect(ctx).not_to match(/iteration budget exhausted/)
|
|
186
186
|
end
|
|
187
|
+
|
|
188
|
+
it 'does not classify or extinguish scars as unauthorized recon' do
|
|
189
|
+
src = File.read(described_class.method(:extinguish!).source_location.first)
|
|
190
|
+
expect(src).not_to match(/unauthorized_recon/)
|
|
191
|
+
expect(src).not_to match(/recon_blocked/)
|
|
192
|
+
expect(src).not_to match(/auth_gate/)
|
|
193
|
+
end
|
|
187
194
|
end
|
|
@@ -36,15 +36,12 @@ describe 'P0 signal hygiene (handler + inbox + policy-cold + calibrate)' do
|
|
|
36
36
|
expect(r[:hint].to_s).to match(/bash-only/i)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
it '
|
|
39
|
+
it 'does not refuse pwn_eval host-discovery as unauthorized' do
|
|
40
|
+
src = File.read('/opt/pwn/lib/pwn/ai/agent/tools/ruby_eval.rb')
|
|
41
|
+
expect(src).not_to match(/recon_blocked/)
|
|
40
42
|
handler = PWN::AI::Agent::Registry.lookup(name: 'pwn_eval').handler
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
allow(PWN::Env).to receive(:dig).with(:ai, :agent, :recon_authorized).and_return(nil)
|
|
44
|
-
r = handler.call(code: 'PWN::Plugins::NmapIt.help')
|
|
45
|
-
expect(r[:error]).to eq('unauthorized_recon_blocked')
|
|
46
|
-
ensure
|
|
47
|
-
Thread.current[:pwn_recon_authorized] = nil
|
|
43
|
+
r = handler.call(code: '1 + 1')
|
|
44
|
+
expect(r[:error]).not_to eq('unauthorized_recon_blocked')
|
|
48
45
|
end
|
|
49
46
|
|
|
50
47
|
it 'Dispatch aliases value= onto command; handler reports schema errors' do
|
|
@@ -46,24 +46,16 @@ describe 'PWN::AI::Agent::Tools shell' do
|
|
|
46
46
|
expect(r[:stdout].to_s).to include('holdout_ok_30e55')
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
it '
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(
|
|
55
|
-
|
|
56
|
-
expect(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
it 'allows commands when recon_authorized thread flag is set' do
|
|
62
|
-
Thread.current[:pwn_recon_authorized] = true
|
|
63
|
-
r = handler.call(command: 'echo allowed_hping_guard')
|
|
49
|
+
it 'does not refuse sweep-shaped commands as unauthorized' do
|
|
50
|
+
src = File.read('/opt/pwn/lib/pwn/ai/agent/tools/shell.rb')
|
|
51
|
+
expect(src).not_to match(/recon_blocked/)
|
|
52
|
+
expect(src).not_to match(/recon_authorized\?/)
|
|
53
|
+
expect(PWN::AI::Agent::ToolGuard).not_to respond_to(:recon_authorized?)
|
|
54
|
+
expect(PWN::AI::Agent::ToolGuard).not_to respond_to(:recon_text?)
|
|
55
|
+
guard = File.read('/opt/pwn/lib/pwn/ai/agent/tool_guard.rb')
|
|
56
|
+
expect(guard).not_to match(/RECON_RX/)
|
|
57
|
+
r = handler.call(command: 'echo sweep_guard_removed')
|
|
58
|
+
expect(r[:error]).not_to eq('unauthorized_recon_blocked')
|
|
64
59
|
expect(r[:exit]).to eq(0)
|
|
65
|
-
expect(r[:stdout].to_s).to include('allowed_hping_guard')
|
|
66
|
-
ensure
|
|
67
|
-
Thread.current[:pwn_recon_authorized] = nil
|
|
68
60
|
end
|
|
69
61
|
end
|
|
@@ -47,8 +47,8 @@ describe PWN::AI::Anthropic do
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
it '
|
|
51
|
-
expect(described_class).
|
|
50
|
+
it 'does not expose get_plan_usage' do
|
|
51
|
+
expect(described_class).not_to respond_to(:get_plan_usage)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
describe 'Hermes prompt-cache breakpoint' do
|
|
@@ -13,8 +13,8 @@ describe PWN::AI::Gemini do
|
|
|
13
13
|
expect(help_response).to respond_to :help
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
it '
|
|
17
|
-
expect(described_class).
|
|
16
|
+
it 'does not expose get_plan_usage' do
|
|
17
|
+
expect(described_class).not_to respond_to(:get_plan_usage)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
it 'chat_with_tools honors PromptCache when prompt_cache is on' do
|
|
@@ -18,8 +18,8 @@ describe PWN::AI::Grok do
|
|
|
18
18
|
expect(src).to match(/openai_wire_messages/)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
it '
|
|
22
|
-
expect(described_class).
|
|
21
|
+
it 'does not expose get_plan_usage' do
|
|
22
|
+
expect(described_class).not_to respond_to(:get_plan_usage)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
it 'grok_rest_call swallows ReadTimeout quietly without ERROR: print' do
|