pwn 0.5.686 → 0.5.688

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.ruby-version +1 -1
  4. data/Gemfile +3 -3
  5. data/README.md +3 -4
  6. data/documentation/Agent-Tool-Registry.md +2 -2
  7. data/documentation/Configuration.md +5 -4
  8. data/documentation/General-PWN-Usage.md +1 -1
  9. data/documentation/Home.md +1 -1
  10. data/documentation/How-PWN-Works.md +1 -1
  11. data/documentation/Mistakes.md +1 -1
  12. data/documentation/Reinforcement-Learning.md +4 -6
  13. data/documentation/Troubleshooting.md +1 -1
  14. data/documentation/pwn-REPL.md +1 -1
  15. data/documentation/pwn-ai-Agent.md +2 -3
  16. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  17. data/lib/pwn/ai/agent/dispatch.rb +1 -1
  18. data/lib/pwn/ai/agent/learning.rb +150 -32
  19. data/lib/pwn/ai/agent/loop.rb +291 -129
  20. data/lib/pwn/ai/agent/prompt_builder.rb +47 -29
  21. data/lib/pwn/ai/agent/registry.rb +2 -1
  22. data/lib/pwn/ai/agent/reward.rb +9 -0
  23. data/lib/pwn/ai/agent/swarm.rb +3 -1
  24. data/lib/pwn/ai/agent/task_summarizer.rb +23 -6
  25. data/lib/pwn/ai/agent/tool_guard.rb +109 -0
  26. data/lib/pwn/ai/agent/tools/ruby_eval.rb +44 -20
  27. data/lib/pwn/ai/agent/tools/shell.rb +17 -4
  28. data/lib/pwn/ai/agent/tools/skills.rb +63 -2
  29. data/lib/pwn/ai/anthropic.rb +25 -5
  30. data/lib/pwn/ai/gemini.rb +25 -5
  31. data/lib/pwn/ai/grok.rb +30 -13
  32. data/lib/pwn/ai/http_retry.rb +78 -0
  33. data/lib/pwn/ai/ollama.rb +25 -5
  34. data/lib/pwn/ai/open_ai.rb +26 -13
  35. data/lib/pwn/ai/open_web_ui.rb +25 -5
  36. data/lib/pwn/ai.rb +1 -0
  37. data/lib/pwn/config.rb +1 -1
  38. data/lib/pwn/plugins/log.rb +183 -41
  39. data/lib/pwn/plugins/repl.rb +50 -15
  40. data/lib/pwn/plugins/transparent_browser.rb +16 -2
  41. data/lib/pwn/sessions.rb +50 -1
  42. data/lib/pwn/version.rb +1 -1
  43. data/spec/integration/reinforced_feedback_loop_spec.rb +9 -11
  44. data/spec/lib/pwn/ai/agent/loop_spec.rb +180 -20
  45. data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +3 -0
  46. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  47. data/spec/lib/pwn/ai/agent/reward_spec.rb +13 -0
  48. data/spec/lib/pwn/ai/agent/swarm_spec.rb +6 -0
  49. data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +36 -0
  50. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +46 -0
  51. data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +24 -0
  52. data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +10 -0
  53. data/spec/lib/pwn/ai/agent/tools/skills_spec.rb +29 -0
  54. data/spec/lib/pwn/ai/grok_spec.rb +22 -0
  55. data/spec/lib/pwn/ai/http_retry_spec.rb +55 -0
  56. data/spec/lib/pwn/plugins/log_spec.rb +170 -19
  57. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +14 -4
  58. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +8 -0
  59. data/spec/lib/pwn/sessions_spec.rb +24 -0
  60. data/third_party/pwn_rdoc.jsonl +41 -5
  61. data/tmp_pwn_critic_skills_flaw.txt +9 -0
  62. metadata +11 -8
@@ -13,6 +13,9 @@ module PWN
13
13
  authors help to_s inspect class object_id hash eql? == equal? !
14
14
  method public_send send __send__ instance_eval class_eval
15
15
  public_class_method private_class_method
16
+ debug_on? debug_progress start_debug_session quiet_debug_tui!
17
+ loud_debug_tui! budget_scar? effective_count safe_check
18
+ rank_for_request usable_preference? cause_crumb
16
19
  ].freeze
17
20
  TRACE_SKIP_PREFIXES = %w[
18
21
  PWN::Plugins::Log
@@ -107,6 +110,7 @@ module PWN
107
110
 
108
111
  if msg.instance_of?(Interrupt)
109
112
  logger.level = Logger::WARN
113
+ note_interrupt!(where: 'CTRL+C', which_self: which_self) if debug_enabled?
110
114
  if driver_name == 'pwn'
111
115
  log_event += ' => CTRL+C Detected.'
112
116
  else
@@ -124,6 +128,7 @@ module PWN
124
128
 
125
129
  logger.add(logger.level, log_event, which_self)
126
130
  rescue Interrupt
131
+ note_interrupt!(where: 'CTRL+C', which_self: self) if debug_enabled?
127
132
  puts "\n#{self}.#{__method__} => Goodbye."
128
133
  rescue StandardError => e
129
134
  raise e
@@ -141,33 +146,69 @@ module PWN
141
146
  @debug_path
142
147
  end
143
148
 
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
-
149
+ # Open a debug session. Request traces go to
150
+ # /tmp/pwn-ai-DEBUG-<session_id>-RN.log via next_request_log!.
151
+ # opts[:path] is a test override that writes to one file.
148
152
  public_class_method def self.start_debug(opts = {})
149
- return @debug_path if debug_enabled? && opts.is_a?(Hash)
153
+ if debug_enabled? && opts[:path].to_s.empty?
154
+ @debug_tee = opts[:tee] if opts.key?(:tee)
155
+ sid = sanitize_debug_session_id(session_id: opts[:session_id])
156
+ @debug_session_id = sid unless sid.empty?
157
+ return @debug_path
158
+ end
150
159
 
151
- ts = Time.now.strftime('%Y%m%d-%H%M%S')
160
+ stop_trace! if @debug_tp
152
161
  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
162
  @debug_tee = opts[:tee]
164
163
  @debug_tui_quiet = false
165
164
  @debug_enabled = true
166
- start_trace!(prefixes: opts[:prefixes]) unless opts[:trace] == false
167
- progress(msg: "debug session start path=#{path}", which_self: self)
165
+ @debug_session_id = sanitize_debug_session_id(session_id: opts[:session_id])
166
+ @debug_req_n = 0
167
+ @debug_request_open = false
168
+ if path.empty?
169
+ close_debug_file!
170
+ @debug_path = nil
171
+ else
172
+ open_debug_file!(path: path)
173
+ end
174
+ start_trace!(prefixes: opts[:prefixes]) if opts[:trace] == true
175
+ progress(msg: 'debug session start', which_self: self) if @debug_file
176
+ @debug_path
177
+ end
178
+
179
+ public_class_method def self.next_request_log!(opts = {})
180
+ return unless debug_enabled?
181
+ return @debug_path if @debug_request_open && opts[:force] != true
182
+
183
+ sid = sanitize_debug_session_id(session_id: opts[:session_id])
184
+ sid = @debug_session_id if sid.empty?
185
+ sid = 'nosession' if sid.empty?
186
+ @debug_session_id = sid
187
+ n = next_debug_request_n(session_id: sid)
188
+ path = "/tmp/pwn-ai-DEBUG-#{sid}-R#{n}.log"
189
+ open_debug_file!(path: path)
190
+ @debug_req_n = n
191
+ @debug_request_open = true
192
+ progress(msg: "request log R#{n} path=#{path}", which_self: self)
168
193
  path
169
194
  end
170
195
 
196
+ public_class_method def self.finish_request_log!(opts = {})
197
+ return unless debug_enabled?
198
+ return unless @debug_request_open || opts[:force] == true
199
+
200
+ bits = [
201
+ "footer iter=#{opts[:iter].to_i}",
202
+ "tools_called=#{opts[:tools_called].to_i}",
203
+ "engine_s=#{opts[:engine_s].to_f.round(3)}",
204
+ "final_chars=#{opts[:final_chars].to_i}"
205
+ ]
206
+ bits << "nested=#{opts[:nested]}" unless opts[:nested].to_s.empty?
207
+ progress(msg: bits.join(' '), which_self: self)
208
+ @debug_request_open = false
209
+ @debug_path
210
+ end
211
+
171
212
  public_class_method def self.stop_debug(opts = {})
172
213
  reason = opts[:reason].to_s if opts.is_a?(Hash)
173
214
  if debug_enabled?
@@ -175,37 +216,49 @@ module PWN
175
216
  progress(msg: tail, which_self: self)
176
217
  end
177
218
  stop_trace!
178
- begin
179
- @debug_file&.close unless @debug_file.nil? || @debug_file.closed?
180
- rescue StandardError
181
- nil
182
- end
183
219
  path = @debug_path
184
- @debug_file = nil
220
+ close_debug_file!
185
221
  @debug_path = nil
186
- @debug_stem = nil
187
- @debug_index = nil
222
+ @debug_session_id = nil
223
+ @debug_req_n = nil
224
+ @debug_request_open = false
188
225
  @debug_tee = nil
189
226
  @debug_tui_quiet = false
190
227
  @debug_enabled = false
191
228
  path
192
229
  end
193
230
 
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/
231
+ private_class_method def self.sanitize_debug_session_id(opts = {})
232
+ opts[:session_id].to_s.gsub(/[^A-Za-z0-9._-]/, '_')[0, 80]
233
+ end
197
234
 
198
- [path.sub(/\.log\z/, ''), 1]
235
+ private_class_method def self.next_debug_request_n(opts = {})
236
+ sid = sanitize_debug_session_id(session_id: opts[:session_id])
237
+ max_n = 0
238
+ Dir.glob("/tmp/pwn-ai-DEBUG-#{sid}-R*.log").each do |p|
239
+ n = p[/-R(\d+)\.log\z/, 1].to_i
240
+ max_n = n if n > max_n
241
+ end
242
+ max_n + 1
199
243
  end
200
244
 
201
- private_class_method def self.roll_debug_log!(opts = {})
245
+ private_class_method def self.close_debug_file!(opts = {})
202
246
  return if opts[:skip]
203
- return if @debug_file.nil? || @debug_file.closed?
204
- return if @debug_file.size < DEBUG_LOG_MAX
205
247
 
206
- @debug_file.close
207
- @debug_index = @debug_index.to_i + 1
208
- path = "#{@debug_stem}.#{@debug_index}.log"
248
+ begin
249
+ @debug_file&.close unless @debug_file.nil? || @debug_file.closed?
250
+ rescue StandardError
251
+ nil
252
+ end
253
+ @debug_file = nil
254
+ end
255
+
256
+ private_class_method def self.open_debug_file!(opts = {})
257
+ path = opts[:path].to_s
258
+ return if path.empty?
259
+
260
+ close_debug_file!
261
+ FileUtils.mkdir_p(File.dirname(path))
209
262
  io = File.open(path, 'a')
210
263
  io.sync = true
211
264
  @debug_file = io
@@ -233,11 +286,15 @@ module PWN
233
286
  Thread.current[:pwn_log_progress] = true
234
287
  msg = sanitize_debug_text(text: opts[:msg].to_s)
235
288
  which = opts[:which_self] || self
236
- line = format_progress(msg: msg, which_self: which)
289
+ line = format_progress(
290
+ msg: msg,
291
+ which_self: which,
292
+ keep_newlines: opts[:keep_newlines],
293
+ cap: opts[:cap]
294
+ )
237
295
  begin
238
296
  @debug_file&.puts(line)
239
297
  @debug_file&.flush
240
- roll_debug_log!
241
298
  rescue StandardError
242
299
  nil
243
300
  end
@@ -245,6 +302,8 @@ module PWN
245
302
  if !@debug_tui_quiet && tee.respond_to?(:puts)
246
303
  begin
247
304
  tee.puts(color_progress(line: line))
305
+ tee.flush if tee.respond_to?(:flush)
306
+ $stdout.flush if $stdout.respond_to?(:flush)
248
307
  rescue StandardError
249
308
  nil
250
309
  end
@@ -254,8 +313,88 @@ module PWN
254
313
  Thread.current[:pwn_log_progress] = false
255
314
  end
256
315
 
316
+ public_class_method def self.note_interrupt!(opts = {})
317
+ return false unless debug_enabled?
318
+
319
+ where = opts[:where].to_s
320
+ where = 'CTRL+C' if where.empty?
321
+ # CTRL+C can land mid-progress (HTTP spinner tee, tool row mirror).
322
+ # Clear the reentrancy latch so the Interrupt stamp always lands in the
323
+ # open RN file before the ensure footer / process unwind.
324
+ Thread.current[:pwn_log_progress] = false
325
+ at = Time.now
326
+ local_ts = at.strftime('%Y-%m-%d %H:%M:%S.%L%z')
327
+ msg = "Interrupt #{where} at=#{at.utc.iso8601(3)}"
328
+ which = opts[:which_self] || self
329
+ # Prefer the normal DEBUG stamp path; fall back to a direct file write
330
+ # if progress is still unavailable for any reason.
331
+ ok = progress(msg: msg, which_self: which)
332
+ unless ok
333
+ begin
334
+ who = which.is_a?(Module) ? (which.name || which.to_s) : which.to_s
335
+ line = "[DEBUG #{local_ts}] #{who} #{msg}".strip
336
+ @debug_file&.puts(line)
337
+ @debug_file&.flush
338
+ ok = true
339
+ rescue StandardError
340
+ ok = false
341
+ end
342
+ end
343
+ # Also clone the operator-facing TUI shape into the RN file.
344
+ mirror_tui!(msg: "[ #{local_ts} → pwn-ai → Interrupt ] #{where}")
345
+ ok
346
+ end
347
+
348
+ public_class_method def self.note_exception!(opts = {})
349
+ return false unless debug_enabled?
350
+
351
+ err = opts[:error]
352
+ return false unless err.respond_to?(:message)
353
+
354
+ where = opts[:where].to_s
355
+ where = 'Loop.run' if where.empty?
356
+ bt = Array(err.backtrace).join("\n")
357
+ progress(
358
+ msg: "exception #{where} #{err.class}: #{err.message}\n#{bt}",
359
+ which_self: opts[:which_self] || self,
360
+ keep_newlines: true,
361
+ cap: 0
362
+ )
363
+ end
364
+
365
+ # Clone operator-visible TUI rows into the open RN request log (no ANSI).
366
+ # Used for [ ts → pwn-ai → tool ] / → result / task briefs so the file
367
+ # matches what the operator saw without needing a TracePoint dump.
368
+ public_class_method def self.mirror_tui!(opts = {})
369
+ return unless debug_enabled?
370
+ return unless opts.is_a?(Hash)
371
+
372
+ text = opts[:msg].to_s
373
+ text = text.gsub(/\e\[[0-9;]*m/, '')
374
+ text = sanitize_debug_text(text: text)
375
+ return if text.strip.empty?
376
+
377
+ begin
378
+ io = @debug_file
379
+ return if io.nil? || (io.respond_to?(:closed?) && io.closed?)
380
+
381
+ io.write(text.end_with?("\n") ? text : "#{text}\n")
382
+ io.flush
383
+ rescue StandardError
384
+ return
385
+ end
386
+ text
387
+ end
388
+
257
389
  private_class_method def self.format_progress(opts = {})
258
- msg = opts[:msg].to_s.tr("\n", ' ')[0, 4_000]
390
+ cap = if opts.key?(:cap)
391
+ opts[:cap].to_i
392
+ else
393
+ 4_000
394
+ end
395
+ msg = opts[:msg].to_s
396
+ msg = msg.tr("\n", ' ') unless opts[:keep_newlines]
397
+ msg = msg[0, cap] if cap.positive? && msg.length > cap
259
398
  which = opts[:which_self]
260
399
  who = if which.is_a?(Module)
261
400
  (which.name || which.to_s).to_s
@@ -271,7 +410,7 @@ module PWN
271
410
 
272
411
  private_class_method def self.color_progress(opts = {})
273
412
  line = opts[:line].to_s
274
- "\001\e[35m\002#{line}\001\e[0m\002"
413
+ "\e[35m#{line}\e[0m"
275
414
  end
276
415
 
277
416
  private_class_method def self.trace_module_name(opts = {})
@@ -414,10 +553,13 @@ module PWN
414
553
 
415
554
  path = #{self}.start_debug(
416
555
  tee: $stdout,
417
- path: 'optional - defaults to /tmp/pwn-ai-DEBUG-TIMESTAMP.log'
556
+ session_id: 'optional - pwn-ai session id'
418
557
  )
558
+ #{self}.next_request_log!(session_id: 'optional')
419
559
  #{self}.progress(msg: 'stage', which_self: self)
560
+ #{self}.finish_request_log!(iter: 1, tools_called: 0, engine_s: 0.2, final_chars: 12)
420
561
  #{self}.stop_debug
562
+ # start_debug(trace: true) enables per-call TracePoint (opt-in)
421
563
  "
422
564
  end
423
565
  end
@@ -471,15 +471,14 @@ module PWN
471
471
  PWN::Config.load_memory
472
472
  mem_count = (PWN.const_defined?(:Memory) ? PWN::Memory.load.keys.length : 0)
473
473
  sess = begin
474
- PWN::Sessions.create(title: "pwn-ai #{Time.now.strftime('%Y-%m-%d %H:%M')}", source: 'pwn-ai-repl')
474
+ PWN::Sessions.create(title: "pwn-ai #{Time.now.strftime('%Y-%m-%d %H:%M')}", source: 'pwn-ai')
475
475
  rescue StandardError
476
476
  nil
477
477
  end
478
478
  pi.config.pwn_ai_session_id = sess[:id] if sess
479
479
  cron_count = (PWN.const_defined?(:Cron) ? PWN::Cron.list.keys.length : 0)
480
480
 
481
- puts "\
482
- [*] pwn-ai agent TUI activated (PWN REPL driver w/ memory, sessions, delegation, cron)."
481
+ puts '[*] pwn-ai agent TUI activated (PWN REPL driver w/ memory, sessions, delegation, cron).'
483
482
  puts "[*] Memory facts: #{mem_count} | Session: #{pi.config.pwn_ai_session_id} | Cron jobs: #{cron_count} | Skills: #{skills_count}"
484
483
  puts '[*] Instruct the AI agent to carry out a task, e.g.:'
485
484
  puts " 'Use NmapIt to port scan target.com then use TransparentBrowser to spider and SAST::TestCaseEngine to analyze code if cloned. Generate report with PWN::Reports.'"
@@ -488,6 +487,8 @@ module PWN
488
487
  puts "[*] Type 'back' to exit pwn-ai mode."
489
488
  puts '[*] MULTILINE in pwn-ai: SHIFT+ENTER (or ALT+ENTER, or trailing `\\`) inserts a newline; ENTER submits to the AI.'
490
489
  puts "[*] tmux + terminator users: Ensure ~/.tmux.conf has 'set -s extended-keys on' and 'set -g xterm-keys on', then restart tmux. Use TERM=xterm-256color."
490
+ tag = pi.config.pwn_ai_session_id.to_s.empty? ? '<SESSION_ID>' : pi.config.pwn_ai_session_id
491
+ puts "\n\n\npwn-ai debug ON → /tmp/pwn-ai-DEBUG-#{tag}-R<REQUEST_NUMBER>.log"
491
492
  end
492
493
  end
493
494
 
@@ -1043,8 +1044,8 @@ module PWN
1043
1044
  end
1044
1045
  end
1045
1046
 
1046
- Pry::Commands.create_command 'toggle-pwn-ai-debug' do
1047
- description 'Stream pwn-ai module progress to the TUI and /tmp/pwn-ai-DEBUG-TIMESTAMP.log'
1047
+ Pry::Commands.create_command 'toggle-debug' do
1048
+ description 'Stream pwn-ai stage log to the TUI and /tmp/pwn-ai-DEBUG-<SESSION_ID>-RN.log (trace: Env ai.agent.debug_trace)'
1048
1049
 
1049
1050
  def process
1050
1051
  pi = pry_instance
@@ -1057,9 +1058,10 @@ module PWN
1057
1058
  output.puts 'pwn-ai debug OFF'
1058
1059
  end
1059
1060
  else
1060
- path = PWN::Plugins::Log.start_debug(tee: output)
1061
+ sid = pi.config.pwn_ai_session_id
1062
+ PWN::Plugins::Log.start_debug(tee: output, session_id: sid)
1061
1063
  pi.config.pwn_ai_debug = true
1062
- output.puts "pwn-ai debug ON → #{path}"
1064
+ output.puts 'pwn-ai debug ON.'
1063
1065
  end
1064
1066
  end
1065
1067
  end
@@ -1205,22 +1207,49 @@ module PWN
1205
1207
  # belong only to the subsequent per-tool lines (one-to-many).
1206
1208
  on_tool = lambda do |name, args, result|
1207
1209
  # Task summaries are shown in their entirety (multi-line OK).
1208
- # Per-tool arg previews stay capped so the TUI stays readable.
1210
+ # Tool request + result are shown in full (no char cap).
1211
+ # Raw ANSI only — PS1 SOH/STX on live stdout swallows later rows.
1212
+ # When debug is on, the same plain text is mirrored into the
1213
+ # open /tmp pwn-ai-DEBUG-…-RN.log for human troubleshooting.
1214
+ mirror = lambda do |plain|
1215
+ next unless pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
1216
+
1217
+ PWN::Plugins::Log.mirror_tui!(msg: plain)
1218
+ end
1209
1219
  if name.to_s == 'task'
1210
1220
  body = args.is_a?(String) ? args.to_s : args.inspect
1211
1221
  timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
1212
- print "\001\e[33m\002[ #{timestamp} → pwn-ai → task ]\001\e[0m\002\s"
1213
- body.to_s.each_line { |ln| puts "\001\e[32m\002 #{ln.rstrip}\001\e[0m\002" }
1214
- # Never pair a result row with a task brief
1222
+ header = "[ #{timestamp} → pwn-ai → task ]"
1223
+ print "\e[33m#{header}\e[0m "
1224
+ body_out = +''
1225
+ body.to_s.each_line do |ln|
1226
+ puts "\e[32m #{ln.rstrip}\e[0m"
1227
+ body_out << " #{ln.rstrip}\n"
1228
+ end
1229
+ mirror.call("#{header}\n#{body_out}")
1215
1230
  next
1216
1231
  end
1217
1232
 
1218
- arg_preview = args.is_a?(String) ? args[0, 280] : args.inspect[0, 280]
1233
+ argv = args.is_a?(String) ? args.to_s : args.inspect
1219
1234
  timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
1220
- puts "\001\e[33m\002[ #{timestamp} → pwn-ai → #{name} ]\001\e[0m\002 #{arg_preview}"
1235
+ header = "[ #{timestamp} → pwn-ai → #{name} ]"
1236
+ puts "\e[33m#{header}\e[0m"
1237
+ argv_out = +''
1238
+ argv.to_s.each_line do |ln|
1239
+ puts "\e[33m #{ln.rstrip}\e[0m"
1240
+ argv_out << " #{ln.rstrip}\n"
1241
+ end
1221
1242
 
1222
1243
  timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
1223
- puts "\001\e[36m\002#{timestamp} → result → #{result.to_s[0, 700]}\001\e[0m\002\n"
1244
+ res_header = "#{timestamp} → result"
1245
+ puts "\e[36m#{res_header}\e[0m"
1246
+ res_out = +''
1247
+ result.to_s.each_line do |ln|
1248
+ puts "\e[36m #{ln.rstrip}\e[0m"
1249
+ res_out << " #{ln.rstrip}\n"
1250
+ end
1251
+ puts
1252
+ mirror.call("#{header}\n#{argv_out}#{res_header}\n#{res_out}\n")
1224
1253
  end
1225
1254
  final = PWN::AI::Agent::Loop.run(
1226
1255
  request: orig_request,
@@ -1231,8 +1260,9 @@ module PWN
1231
1260
  debug_tee: $stdout
1232
1261
  )
1233
1262
  $stdout.flush
1234
- puts "\n\001\e[32m\002#{final}\001\e[0m\002\n\n"
1263
+ puts "\n\e[32m#{final}\e[0m\n\n"
1235
1264
  $stdout.flush
1265
+ PWN::Plugins::Log.mirror_tui!(msg: "\n#{final}\n\n") if pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
1236
1266
  if pi.config.pwn_ai_debug && sess_id && PWN.const_defined?(:Sessions)
1237
1267
  PWN::Plugins::Log.progress(
1238
1268
  msg: "session=#{sess_id}",
@@ -1241,7 +1271,12 @@ module PWN
1241
1271
  end
1242
1272
  request.replace('nil')
1243
1273
  next
1274
+ rescue Interrupt
1275
+ Thread.current[:pwn_log_progress] = false
1276
+ PWN::Plugins::Log.note_interrupt!(where: 'CTRL+C', which_self: PWN::Plugins::REPL) if pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
1277
+ raise
1244
1278
  rescue StandardError => e
1279
+ PWN::Plugins::Log.note_exception!(error: e, where: 'native agent loop', which_self: PWN::Plugins::REPL) if defined?(PWN::Plugins::Log) && PWN::Plugins::Log.respond_to?(:note_exception!)
1245
1280
  warn "[pwn-ai] native agent loop failed (#{e.class}: #{e.message}\n#{e.backtrace}); " \
1246
1281
  'falling back to legacy regex-ReAct.'
1247
1282
  ensure
@@ -41,6 +41,17 @@ module PWN
41
41
  raise e
42
42
  end
43
43
 
44
+ private_class_method def self.apply_driver_timeouts!(opts = {})
45
+ browser = opts[:browser_obj].is_a?(Hash) ? opts[:browser_obj][:browser] : nil
46
+ return unless browser.respond_to?(:driver)
47
+
48
+ timeouts = browser.driver.manage.timeouts
49
+ timeouts.page_load = 45
50
+ timeouts.script = 30
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
44
55
  # Supported Method Parameters::
45
56
  # browser_obj1 = PWN::Plugins::TransparentBrowser.open(
46
57
  # browser_type: 'optional - :firefox|:chrome|:headless|:rest|:websocket (defaults to :chrome)',
@@ -66,8 +77,10 @@ module PWN
66
77
  devtools = opts[:devtools] ||= false
67
78
  devtools = true if devtools_supported.include?(browser_type) && devtools
68
79
 
69
- # Let's crank up the default timeout from 30 seconds to 15 min for slow sites
70
- Watir.default_timeout = 900
80
+ # Element waits (Watir) vs navigation (Selenium page_load) are
81
+ # separate. 900s element waits made Wix browser.links.map hang for
82
+ # hours. Keep navigation bounded too.
83
+ Watir.default_timeout = 15 unless %i[rest websocket].include?(browser_type)
71
84
 
72
85
  args = []
73
86
  # args.push('--start-maximized')
@@ -365,6 +378,7 @@ module PWN
365
378
  browser_obj[:devtools] = browser_obj[:browser].driver.bidi if firefox_types.include?(browser_type)
366
379
  end
367
380
 
381
+ apply_driver_timeouts!(browser_obj: browser_obj)
368
382
  browser_obj
369
383
  rescue StandardError => e
370
384
  puts e.backtrace
data/lib/pwn/sessions.rb CHANGED
@@ -48,7 +48,7 @@ module PWN
48
48
  # Supported Method Parameters::
49
49
  # session = PWN::Sessions.create(
50
50
  # title: 'optional - human title',
51
- # source: 'optional - e.g. pwn-ai-repl',
51
+ # source: 'optional - e.g. pwn-ai',
52
52
  # id: 'optional - fixed session id (tests / replay); default timestamp_hex'
53
53
  # )
54
54
  public_class_method def self.create(opts = {})
@@ -220,6 +220,54 @@ module PWN
220
220
  }
221
221
  end
222
222
 
223
+ # Current-session conversation for the next LLM call. User + assistant
224
+ # only (no tool/observation). Always on — not gated by recall wording.
225
+ # Tail-window by max_chars so a long JSONL cannot blow the context.
226
+ #
227
+ # msgs = PWN::Sessions.to_llm_messages(
228
+ # session_id: 'required',
229
+ # max_chars: 'optional - keep newest turns under this (default 48_000)',
230
+ # skip_request: 'optional - drop a trailing user line equal to this'
231
+ # )
232
+ public_class_method def self.to_llm_messages(opts = {})
233
+ sid = opts[:session_id]
234
+ return [] if sid.to_s.empty?
235
+
236
+ max_chars = opts[:max_chars].to_i
237
+ max_chars = 48_000 if max_chars <= 0
238
+ skip = opts[:skip_request].to_s
239
+ rows = load(session_id: sid).select do |row|
240
+ next false unless row.is_a?(Hash)
241
+
242
+ role = row[:role].to_s
243
+ next false unless %w[user assistant].include?(role)
244
+
245
+ body = row[:content].to_s
246
+ next false if body.strip.empty?
247
+
248
+ true
249
+ end
250
+ if !skip.empty? && rows.last && rows.last[:role].to_s == 'user' && rows.last[:content].to_s == skip
251
+ rows = rows[0...-1]
252
+ elsif !skip.empty?
253
+ rows = rows.reject { |row| row[:role].to_s == 'user' && row[:content].to_s == skip }
254
+ end
255
+
256
+ picked = []
257
+ used = 0
258
+ rows.reverse_each do |row|
259
+ body = row[:content].to_s
260
+ n = body.length
261
+ break if picked.any? && (used + n) > max_chars
262
+
263
+ picked.unshift(role: row[:role].to_s, content: body)
264
+ used += n
265
+ end
266
+ picked
267
+ rescue StandardError
268
+ []
269
+ end
270
+
223
271
  # Supported Method Parameters::
224
272
  # PWN::Sessions.delete(
225
273
  # session_id: 'required',
@@ -497,6 +545,7 @@ module PWN
497
545
  PWN::Sessions.append(session_id: sess[:id], role: 'user', content: 'Run NmapIt...')
498
546
  transcript = PWN::Sessions.load(session_id: sess[:id])
499
547
  hist = PWN::Sessions.to_response_history(session_id: sess[:id])
548
+ msgs = PWN::Sessions.to_llm_messages(session_id: sess[:id])
500
549
  PWN::Sessions.recall(query: 'hping3', exclude_session_id: sess[:id])
501
550
  PWN::Sessions.previous_id(exclude_session_id: sess[:id])
502
551
  PWN::Sessions.list
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.686'
4
+ VERSION = '0.5.688'
5
5
  end
@@ -423,6 +423,8 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
423
423
 
424
424
  hint = curriculum.red_team_plan(request: 'goal', plan: "1. shell nmap\n2. shell msfconsole")
425
425
  expect(hint).to start_with('[pwn-ai/red_team]')
426
+ src = File.read(curriculum.method(:red_team_plan).source_location.first)
427
+ expect(src).to match(/text_only:\s*true/)
426
428
  end
427
429
  end
428
430
 
@@ -1478,23 +1480,19 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
1478
1480
  expect(src).not_to match(/n = \[n, hot_cap\]\.min/)
1479
1481
  end
1480
1482
 
1481
- it 'forces tools=nil on last iter and skips counterfactual when budget-hot' do
1483
+ it 'never strips tools because a round cap is about to fire' do
1482
1484
  src = File.read(loop_mod.method(:run).source_location.first)
1483
- expect(src).to match(/FINAL ITERATION/)
1484
- expect(src).to match(/last_iter \? nil : tools/)
1485
- # CF gate requires !budget_exhaustion_hot?
1485
+ expect(src).not_to match(/FINAL ITERATION/)
1486
+ expect(src).not_to match(/last_iter \? nil : tools/)
1486
1487
  expect(src).to match(/!budget_exhaustion_hot\?/)
1487
1488
  expect(src).to match(/Curriculum\.counterfactual/)
1488
1489
  end
1489
1490
 
1490
- it 'exhaust path appends session + auto_introspect (not bare string only)' do
1491
+ it 'does not return a budget-exhausted canned final' do
1491
1492
  src = File.read(loop_mod.method(:run).source_location.first)
1492
- idx = src.index('shape: :budget_exhausted')
1493
- expect(idx).not_to be_nil
1494
- tail = src[idx, 800]
1495
- expect(tail).to match(/append_session/)
1496
- expect(tail).to match(/auto_introspect/)
1497
- expect(tail).to match(/final_msg/)
1493
+ expect(src).not_to match(/shape: :budget_exhausted/)
1494
+ expect(src).not_to match(/iteration budget exhausted/)
1495
+ expect(src).to match(/may_finalize\?/)
1498
1496
  end
1499
1497
 
1500
1498
  it 'STATUS table lists Budget exhaust deepen' do