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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -0
  3. data/documentation/Agent-Tool-Registry.md +1 -3
  4. data/documentation/Configuration.md +0 -2
  5. data/documentation/General-PWN-Usage.md +1 -1
  6. data/documentation/Home.md +2 -2
  7. data/documentation/How-PWN-Works.md +2 -2
  8. data/documentation/Installation.md +18 -2
  9. data/documentation/Persistence.md +1 -1
  10. data/documentation/Session-Workflow.md +8 -28
  11. data/documentation/Skills-Memory-Learning.md +20 -0
  12. data/documentation/Troubleshooting.md +1 -1
  13. data/documentation/Why-PWN.md +1 -1
  14. data/documentation/diagrams/dot/memory-skills-detailed.dot +1 -1
  15. data/documentation/diagrams/dot/overall-pwn-architecture.dot +1 -1
  16. data/documentation/diagrams/dot/persistence-filesystem.dot +1 -1
  17. data/documentation/diagrams/dot/sessions-cron-automation.dot +1 -1
  18. data/documentation/diagrams/memory-skills-detailed.svg +85 -84
  19. data/documentation/diagrams/overall-pwn-architecture.svg +15 -15
  20. data/documentation/diagrams/persistence-filesystem.svg +1 -1
  21. data/documentation/diagrams/sessions-cron-automation.svg +7 -6
  22. data/documentation/pwn-REPL.md +1 -1
  23. data/documentation/pwn-ai-Agent.md +4 -7
  24. data/etc/default_skills/bug-bounty-hunting/SKILL.md +70 -0
  25. data/etc/default_skills/deep-exploitation/SKILL.md +74 -0
  26. data/etc/default_skills/hardware-and-firmware-testing/SKILL.md +83 -0
  27. data/etc/default_skills/osint/SKILL.md +147 -0
  28. data/etc/default_skills/penetration-testing/SKILL.md +81 -0
  29. data/etc/default_skills/red-teaming/SKILL.md +70 -0
  30. data/etc/default_skills/reverse-engineering-binaries/SKILL.md +70 -0
  31. data/etc/default_skills/sast-code-scans/SKILL.md +66 -0
  32. data/etc/default_skills/social-engineering/SKILL.md +67 -0
  33. data/etc/default_skills/vulnerability-research-fundamentals/SKILL.md +74 -0
  34. data/etc/default_skills/web-application-penetration-testing/SKILL.md +68 -0
  35. data/lib/pwn/ai/agent/loop.rb +79 -41
  36. data/lib/pwn/ai/agent/mistakes.rb +6 -6
  37. data/lib/pwn/ai/agent/prompt_builder.rb +3 -4
  38. data/lib/pwn/ai/agent/tool_guard.rb +1 -46
  39. data/lib/pwn/ai/agent/tools/ruby_eval.rb +0 -10
  40. data/lib/pwn/ai/agent/tools/shell.rb +0 -6
  41. data/lib/pwn/ai/anthropic.rb +0 -118
  42. data/lib/pwn/ai/gemini.rb +0 -15
  43. data/lib/pwn/ai/grok.rb +0 -158
  44. data/lib/pwn/ai/ollama.rb +0 -12
  45. data/lib/pwn/ai/open_ai.rb +0 -110
  46. data/lib/pwn/ai/open_web_ui.rb +0 -12
  47. data/lib/pwn/ai.rb +0 -86
  48. data/lib/pwn/config.rb +62 -1
  49. data/lib/pwn/migrate.rb +2 -0
  50. data/lib/pwn/plugins/log.rb +302 -0
  51. data/lib/pwn/plugins/repl.rb +28 -8
  52. data/lib/pwn/version.rb +1 -1
  53. data/spec/integration/config_spec.rb +37 -0
  54. data/spec/lib/pwn/ai/agent/loop_spec.rb +52 -12
  55. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +7 -0
  56. data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +5 -8
  57. data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -18
  58. data/spec/lib/pwn/ai/anthropic_spec.rb +2 -2
  59. data/spec/lib/pwn/ai/gemini_spec.rb +2 -2
  60. data/spec/lib/pwn/ai/grok_spec.rb +2 -2
  61. data/spec/lib/pwn/ai/ollama_spec.rb +2 -7
  62. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -23
  63. data/spec/lib/pwn/ai/open_web_ui_spec.rb +2 -7
  64. data/spec/lib/pwn/ai_spec.rb +4 -21
  65. data/spec/lib/pwn/plugins/log_spec.rb +139 -0
  66. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +31 -0
  67. data/spec/lib/pwn/plugins/repl_spec.rb +4 -3
  68. data/third_party/pwn_rdoc.jsonl +29 -24
  69. metadata +12 -2
  70. 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)
@@ -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
@@ -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}/#{plan_usage_glyph}" if model
382
- pname = "pwn.ai:#{engine}/#{model}/#{current_context_length}/#{plan_usage_glyph}.SPEAK" if pi.config.pwn_ai_speak
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 'Display the response_history object while using pwn.ai'
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
- pi.config.pwn_ai_debug ? pi.config.pwn_ai_debug = false : pi.config.pwn_ai_debug = true
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
- pp PWN::Sessions.load(session_id: sess_id) if pi.config.pwn_ai_debug && sess_id && PWN.const_defined?(:Sessions)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.684'
4
+ VERSION = '0.5.686'
5
5
  end
@@ -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 (ollama/openwebui how-to + recon guard)' do
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 'run does not short-circuit statement/question — they take the full tool loop' do
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(/kind\.to_sym == :statement/)
639
- expect(src).not_to match(/kind\.to_sym == :question/)
640
- expect(src).not_to match(/Request type/)
641
- expect(src).to match(/Every request gets a task compass/)
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 'recon_authorized? requires scope language or env flag' do
672
- expect(described_class.recon_authorized?(request: 'find live hosts on this subnet')).to eq(false)
673
- expect(described_class.recon_authorized?(request: 'authorized engagement: find live hosts on this lab subnet')).to eq(true)
674
- allow(PWN::Env).to receive(:dig).and_call_original
675
- allow(PWN::Env).to receive(:dig).with(:ai, :agent, :recon_authorized).and_return(true)
676
- expect(described_class.recon_authorized?(request: 'find live hosts')).to eq(true)
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 'blocks unauthorized recon inside pwn_eval' do
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
- Thread.current[:pwn_recon_authorized] = false
42
- allow(PWN::Env).to receive(:dig).and_call_original
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 'blocks unauthorized hping3 subnet sweep style commands' do
50
- Thread.current[:pwn_recon_authorized] = false
51
- allow(PWN::Env).to receive(:dig).and_call_original
52
- allow(PWN::Env).to receive(:dig).with(:ai, :agent, :recon_authorized).and_return(nil)
53
- r = handler.call(command: 'hping3 -1 -c 1 192.168.1.1')
54
- expect(r[:error]).to eq('unauthorized_recon_blocked')
55
- expect(r[:exit]).to eq(126)
56
- expect(r[:stderr].to_s).to match(/blocked unauthorized recon/)
57
- ensure
58
- Thread.current[:pwn_recon_authorized] = nil
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 'exposes get_plan_usage for the PS1 subscription suffix' do
51
- expect(described_class).to respond_to(:get_plan_usage)
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 'exposes get_plan_usage for the PS1 subscription suffix' do
17
- expect(described_class).to respond_to(:get_plan_usage)
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 'exposes get_plan_usage for the PS1 subscription suffix' do
22
- expect(described_class).to respond_to(:get_plan_usage)
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