pwn 0.5.696 → 0.5.698

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74bfcd98ff2b783dab4bfc6d97a3f8124e00c395f686c05303002e312d5b9596
4
- data.tar.gz: 54d5d1abe1a695c1fb9dd7757becf2648857ff46dd96d9462ee5f73219a13146
3
+ metadata.gz: c333795b07ea88fb7ea338e6b01faf768c62bd07757328fc1698972d43a6c5e8
4
+ data.tar.gz: 0cfbe454002f7ffc79571162a96367aaf3d69017d997cb024c4a475ac3bbab7b
5
5
  SHA512:
6
- metadata.gz: b60265a293cf56ae14e0ff8d3f822faa36ea0b530f6d01c2a9bbd5b2c3256ab3525df45f964a13939c77672c76e4c0b67647baeca3c1faf2f987ff9221a04999
7
- data.tar.gz: b600f46b403f45c2418640a9047653759200d4aca46d2d0571992e6ae1d8501f40c1b28fb9515c2d38b9f313932b4441443b57c61c250b0560b7d5466df7bf43
6
+ metadata.gz: 7a699a8308cb00f04bdf85869647ec381009daf04fe4d8c972c62d5ed57b819b4b719672fe4ad64966c316520a2496bdeb4914c002f526e886b877f7b5d11928
7
+ data.tar.gz: 5b2d1fa1f979bc4852304ed09b7122817a71689114f4c4f6a1a46798b01e9d3bb186b8af28aa8d08288c470b01d323735fb252ccc9c45426835d8b717ca8e382
@@ -42,6 +42,11 @@ PWN::AI::Agent::ToolGuard.present(opts)
42
42
  - `invalid_payload`
43
43
  - `host_load`
44
44
  - `deadline_s`
45
+ - `reset_timeout_budget`
46
+ - `mutation_count`
47
+ - `payload_spent`
48
+ - `note_timeout`
49
+ - `next_timeout`
45
50
  - `timeout_lesson`
46
51
  - `timeout_result`
47
52
  - `timeout_prior_count`
@@ -35,6 +35,7 @@ PWN::AI::HttpRetry.timeout_s(opts)
35
35
 
36
36
  - `timeout_s`
37
37
  - `max_attempts`
38
+ - `retryable`
38
39
  - `retry_after_s`
39
40
  - `report_event`
40
41
  - `authors`
@@ -46,6 +46,13 @@ PWN::Plugins::REPL.ready_tty(opts)
46
46
  - `install_pwn_ai_completer`
47
47
  - `restore_pwn_ai_completer`
48
48
  - `pwn_ai_dispatch_slash`
49
+ - `pwn_ai_engines`
50
+ - `pwn_ai_provider_class`
51
+ - `pwn_ai_model_ids`
52
+ - `pwn_ai_list_llms`
53
+ - `pwn_ai_engine_model`
54
+ - `pwn_ai_run_model`
55
+ - `persist_ai_selection`
49
56
  - `pwn_ai_run_cron`
50
57
  - `pwn_ai_run_sessions`
51
58
  - `pwn_ai_run_memory`
@@ -822,7 +822,8 @@ module PWN
822
822
  lesson = ToolGuard.timeout_lesson(
823
823
  tool: name,
824
824
  payload: opts[:args].to_s,
825
- timeout: err.to_s[/timeout after (\d+)/, 1].to_i
825
+ timeout: err.to_s[/timeout after (\d+)/, 1].to_i,
826
+ task: timeout_task(opts)
826
827
  )
827
828
  err = lesson[:error] if lesson[:error].to_s.strip.length.positive?
828
829
  shape = :timeout
@@ -835,6 +836,32 @@ module PWN
835
836
  { ok: true, err: nil, mistake: nil }
836
837
  end
837
838
 
839
+ private_class_method def self.timeout_task(opts = {})
840
+ st = opts[:ts_state]
841
+ if st.respond_to?(:[])
842
+ idx = st[:plan_idx] || st['plan_idx']
843
+ return "task-#{idx}" unless idx.nil?
844
+ end
845
+ if st.respond_to?(:plan_idx)
846
+ idx = st.plan_idx
847
+ return "task-#{idx}" unless idx.nil?
848
+ end
849
+ opts[:session_id].to_s
850
+ rescue StandardError
851
+ opts[:session_id].to_s
852
+ end
853
+
854
+ private_class_method def self.engine_transient?(opts = {})
855
+ err = opts[:error]
856
+ return false unless err
857
+
858
+ return PWN::AI::HttpRetry.retryable?(error: err) if defined?(PWN::AI::HttpRetry) && PWN::AI::HttpRetry.respond_to?(:retryable?)
859
+
860
+ err.message.to_s.match?(/HTTP 50[234]|Gateway Time-out|stream absolute timeout/i)
861
+ rescue StandardError
862
+ false
863
+ end
864
+
838
865
  # E1 — did the environment change under this tool? If Metrics CUSUM
839
866
  # tripped for it in the last hour AND Extrospection.drift shows a
840
867
  # toolchain/net/repo change, blame the WORLD not the AGENT.
@@ -2271,6 +2298,7 @@ module PWN
2271
2298
  start_debug_session(opts)
2272
2299
  loud_debug_tui!(debug: opts[:debug])
2273
2300
  debug_progress(msg: "Loop.run start request=#{request[0, 240]}", debug: opts[:debug])
2301
+ ToolGuard.reset_timeout_budget! if defined?(ToolGuard) && ToolGuard.respond_to?(:reset_timeout_budget!)
2274
2302
  nested = defined?(TurnFinalizer) && TurnFinalizer.user_path?
2275
2303
  TurnFinalizer.enter_user_path! if defined?(TurnFinalizer)
2276
2304
  engine = active_engine
@@ -2456,6 +2484,7 @@ module PWN
2456
2484
 
2457
2485
  turn_fails = Hash.new(0)
2458
2486
  escalated = false
2487
+ engine_blips = 0
2459
2488
  maybe_park_budget_scars!
2460
2489
  maybe_extinguish_parked!
2461
2490
 
@@ -2469,7 +2498,27 @@ module PWN
2469
2498
  inject_task_focus!(messages: messages, state: ts_state, request: request) unless skip_compass
2470
2499
 
2471
2500
  t0 = Time.now
2472
- msg = call_engine(messages: messages, tools: tools, ts_state: ts_state)
2501
+ begin
2502
+ msg = call_engine(messages: messages, tools: tools, ts_state: ts_state)
2503
+ rescue StandardError => e
2504
+ if engine_transient?(error: e)
2505
+ engine_blips += 1
2506
+ debug_progress(msg: "engine hop failed #{e.class}: #{e.message.to_s[0, 240]} blip=#{engine_blips}")
2507
+ if engine_blips >= 5
2508
+ txt = "[pwn-ai] engine hop failed after #{engine_blips} tries: #{e.message.to_s[0, 240]}"
2509
+ debug_final_text!(text: txt)
2510
+ final_chars = txt.length
2511
+ return txt
2512
+ end
2513
+ messages << {
2514
+ role: 'user',
2515
+ content: '[pwn-ai] engine hop failed (transient). Keep calling CORE_TOOLS. ' \
2516
+ "Do not stop. (#{e.message.to_s[0, 180]})"
2517
+ }
2518
+ next
2519
+ end
2520
+ raise
2521
+ end
2473
2522
  engine_s += (Time.now - t0)
2474
2523
  PWN::Plugins::TTYSpinner.halt_all! if defined?(PWN::Plugins::TTYSpinner)
2475
2524
  if msg.nil?
@@ -100,9 +100,10 @@ module PWN
100
100
 
101
101
  HOST LOAD
102
102
  #{host_load_block}
103
- On pwn_eval/shell timeout: reconstruct the ruby/command for the
104
- same goal first. Do not first raise timeout. Only if construction
105
- is sound, raise a conservative HOST LOAD timeout (clamped).
103
+ On pwn_eval/shell timeout: keep the same ruby/command and retry
104
+ with timeout += 180 until it finishes or the 3-hour budget is
105
+ gone. Only then rewrite the payload for the same goal (max 10
106
+ mutations per task). Any payload.
106
107
  Record a mistake either way.
107
108
 
108
109
  AUTONOMY
@@ -573,8 +573,8 @@ module PWN
573
573
  # model cannot stall the user's real answer, and stay quiet on timeout.
574
574
  private_class_method def self.sidecar_timeout
575
575
  n = (PWN::Env.dig(:ai, :agent, :sidecar_llm_timeout) if defined?(PWN::Env)).to_i
576
- n = 20 if n < 5
577
- n = 60 if n > 60
576
+ n = 180 if n < 30
577
+ n = 180 if n > 180
578
578
  n
579
579
  rescue StandardError
580
580
  20
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module PWN
4
6
  module AI
5
7
  module Agent
@@ -115,82 +117,176 @@ module PWN
115
117
  { ncpu: 1, load1: 0.0, mem_avail_mb: 0 }
116
118
  end
117
119
 
120
+ TIMEOUT_STEP_S = 180
121
+ TIMEOUT_MAX_S = 10_800
122
+ MUTATION_MAX = 10
123
+
118
124
  # Conservative wall-clock seconds for shell / pwn_eval.
119
- # Explicit model estimate is honored then clamped. Omit → host-derived
120
- # default from loadavg / ncpu / MemAvailable (loaded hosts get a bit
121
- # more time, still capped).
125
+ # Explicit timeout is honored for any payload (1..TIMEOUT_MAX_S).
126
+ # Omit → host-derived default from loadavg / ncpu / MemAvailable.
127
+ # No tool-name sniffing: a 65k scan and `ls` use the same math.
122
128
  public_class_method def self.deadline_s(opts = {})
123
129
  kind = opts[:kind].to_s.to_sym
124
- max = kind == :shell ? 180 : 90
125
130
  asked = opts[:timeout] || opts[:timeout_s]
126
131
  asked_i = asked.to_i
127
- if asked_i.positive?
128
- lo = 1
129
- hi = max
130
- return asked_i.clamp(lo, hi)
131
- end
132
+ return asked_i.clamp(1, TIMEOUT_MAX_S) if asked_i.positive?
132
133
 
133
134
  snap = host_load
134
135
  ncpu = [snap[:ncpu].to_i, 1].max
135
136
  load1 = snap[:load1].to_f
136
137
  mem = snap[:mem_avail_mb].to_i
138
+ default_max = kind == :shell ? 180 : 90
137
139
  base = kind == :shell ? 30 : 20
138
140
  base += 15 if load1 > ncpu
139
141
  base += 10 if load1 > (ncpu * 1.5)
140
142
  base += 10 if mem.positive? && mem < 512
141
- base.clamp(8, max)
143
+ base.clamp(8, default_max)
144
+ end
145
+
146
+ public_class_method def self.reset_timeout_budget(opts = {})
147
+ return :noop unless opts.is_a?(Hash)
148
+
149
+ @timeout_spent = {}
150
+ @timeout_mutations = {}
151
+ @timeout_mutated = {}
152
+ :reset
153
+ end
154
+
155
+ public_class_method def self.reset_timeout_budget!
156
+ reset_timeout_budget
157
+ end
158
+
159
+ public_class_method def self.mutation_count(opts = {})
160
+ return 0 unless opts.is_a?(Hash)
161
+
162
+ timeout_mutations[task_key(opts)].to_i
163
+ end
164
+
165
+ public_class_method def self.payload_spent(opts = {})
166
+ return 0 unless opts.is_a?(Hash)
167
+
168
+ timeout_spent[payload_key(opts)].to_i
169
+ end
170
+
171
+ public_class_method def self.note_timeout!(opts = {})
172
+ return 0 unless opts.is_a?(Hash)
173
+
174
+ timeout = opts[:timeout].to_i
175
+ timeout = 1 if timeout < 1
176
+ key = payload_key(opts)
177
+ timeout_spent[key] = timeout_spent[key].to_i + timeout
178
+ if budget_exhausted?(opts.merge(spent: timeout_spent[key])) && !timeout_mutated[key]
179
+ timeout_mutated[key] = true
180
+ tkey = task_key(opts)
181
+ timeout_mutations[tkey] = timeout_mutations[tkey].to_i + 1
182
+ end
183
+ timeout_spent[key]
184
+ end
185
+
186
+ public_class_method def self.next_timeout(opts = {})
187
+ base = opts[:timeout].to_i
188
+ base = 1 if base < 1
189
+ spent = opts.key?(:spent) ? opts[:spent].to_i : payload_spent(opts)
190
+ remaining = TIMEOUT_MAX_S - spent
191
+ remaining = 0 if remaining.negative?
192
+ [base + TIMEOUT_STEP_S, remaining, TIMEOUT_MAX_S].min
142
193
  end
143
194
 
144
- # Two-scenario timeout policy (loop-law, not a skill):
145
- # 1. First timeout reconstruct the ruby/command for the same goal.
146
- # Do not first raise timeout.
147
- # 2. A later timeout of the same tool → deadline was too short; raise
148
- # a conservative HOST LOAD timeout (still clamped).
195
+ # Timeout policy (loop-law, not a skill):
196
+ # 1. Same payload: timeout += 180 until the 3-hour budget is gone.
197
+ # 2. At the 3-hour cap: rewrite ruby/command for the same goal
198
+ # (one mutation). Max MUTATION_MAX mutations per task.
199
+ # 3. After MUTATION_MAX mutations: stop (exhausted).
149
200
  public_class_method def self.timeout_lesson(opts = {})
150
201
  return { scenario: :construction, error: '', hint: '' } unless opts.is_a?(Hash)
151
202
 
152
203
  tool = opts[:tool].to_s
153
204
  timeout = opts[:timeout].to_i
154
- prior = timeout_prior_count(tool: tool)
155
- if prior.positive?
156
- {
157
- scenario: :deadline,
158
- error: "#{tool} timeout: deadline too short; raise conservative HOST LOAD timeout",
159
- hint: "Scenario 2: the #{tool} payload already timed out once. " \
160
- "This timeout (#{timeout}s) was too short. Raise a conservative " \
161
- 'timeout from HOST LOAD (clamped). Record this so it does not recur. ' \
162
- 'Do not retry the identical payload at the same deadline.'
163
- }
205
+ spent = payload_spent(opts)
206
+ spent_after = spent >= timeout && timeout.positive? ? spent : spent + [timeout, 1].max
207
+ nxt = next_timeout(timeout: timeout, spent: spent_after)
208
+ mutations = mutation_count(opts)
209
+ if budget_exhausted?(opts.merge(timeout: timeout, spent: spent_after))
210
+ if mutations >= MUTATION_MAX
211
+ {
212
+ scenario: :exhausted,
213
+ error: "#{tool} timeout: #{MUTATION_MAX} mutations exhausted for this task",
214
+ hint: "This task hit the mutation cap (#{MUTATION_MAX} rewrites after " \
215
+ '3-hour budgets). Do not retry the same payload. Report what ' \
216
+ 'was tried and what remains blocked.'
217
+ }
218
+ else
219
+ {
220
+ scenario: :construction,
221
+ error: "#{tool} timeout: 3-hour budget exhausted; reconstruct payload to same goal",
222
+ hint: "The #{tool} payload used its 3-hour budget. Generate different " \
223
+ 'ruby/command for the same goal. Mutation ' \
224
+ "#{[mutations, 1].max}/#{MUTATION_MAX}."
225
+ }
226
+ end
164
227
  else
165
228
  {
166
- scenario: :construction,
167
- error: "#{tool} timeout: reconstruct payload to same goal before raising timeout",
168
- hint: "Scenario 1 (try first): the #{tool} ruby/command was constructed " \
169
- 'improperly. Generate it differently to achieve the same goal. ' \
170
- 'Do not first raise timeout. Record this so it does not recur.'
229
+ scenario: :deadline,
230
+ error: "#{tool} timeout: deadline too short; retry with timeout += 180",
231
+ hint: "Keep the same #{tool} payload. This timeout (#{timeout}s) was too " \
232
+ "short. Retry with timeout += 180 (next_timeout=#{nxt})."
171
233
  }
172
234
  end
173
235
  end
174
236
 
175
237
  public_class_method def self.timeout_result(opts = {})
176
- return { stdout: '', stderr: '', exit: nil, error: 'timeout', scenario: :construction, hint: '', shell: shell_name } unless opts.is_a?(Hash)
238
+ return { stdout: '', stderr: '', exit: nil, error: 'timeout', scenario: :deadline, hint: '', next_timeout: TIMEOUT_STEP_S, shell: shell_name } unless opts.is_a?(Hash)
177
239
 
240
+ timeout = opts[:timeout].to_i
241
+ note_timeout!(opts)
178
242
  lesson = timeout_lesson(
179
243
  tool: opts[:tool],
180
244
  payload: opts[:payload],
181
- timeout: opts[:timeout]
245
+ timeout: timeout,
246
+ task: opts[:task]
182
247
  )
183
248
  {
184
249
  stdout: opts[:stdout].to_s,
185
250
  stderr: opts[:stderr].to_s,
186
251
  exit: nil,
187
- error: "timeout after #{opts[:timeout].to_i}s",
252
+ error: "timeout after #{timeout}s",
188
253
  scenario: lesson[:scenario],
189
254
  hint: lesson[:hint],
255
+ next_timeout: next_timeout(timeout: timeout, spent: payload_spent(opts)),
256
+ mutations: mutation_count(opts),
190
257
  shell: opts[:shell] || shell_name
191
258
  }
192
259
  end
193
260
 
261
+ private_class_method def self.timeout_spent
262
+ @timeout_spent ||= {}
263
+ end
264
+
265
+ private_class_method def self.timeout_mutations
266
+ @timeout_mutations ||= {}
267
+ end
268
+
269
+ private_class_method def self.timeout_mutated
270
+ @timeout_mutated ||= {}
271
+ end
272
+
273
+ private_class_method def self.task_key(opts = {})
274
+ t = opts[:task]
275
+ t = opts[:session_id] if t.to_s.strip.empty?
276
+ t.to_s.strip.empty? ? 'default' : t.to_s
277
+ end
278
+
279
+ private_class_method def self.payload_key(opts = {})
280
+ payload = opts[:payload].to_s
281
+ "#{task_key(opts)}:#{Digest::SHA256.hexdigest(payload)}"
282
+ end
283
+
284
+ private_class_method def self.budget_exhausted?(opts = {})
285
+ spent = opts[:spent]
286
+ spent = payload_spent(opts) if spent.nil?
287
+ opts[:timeout].to_i >= TIMEOUT_MAX_S || spent.to_i >= TIMEOUT_MAX_S
288
+ end
289
+
194
290
  public_class_method def self.timeout_prior_count(opts = {})
195
291
  return 0 unless opts.is_a?(Hash)
196
292
  return 0 unless defined?(PWN::AI::Agent::Mistakes)
@@ -24,7 +24,10 @@ PWN::AI::Agent::Registry.register(
24
24
  'browser. Do not open a second browser. Close once with ' \
25
25
  'Close once with TransparentBrowser.close(browser_obj: browser_obj). ' \
26
26
  'Pass timeout as a conservative integer seconds estimate given HOST ' \
27
- 'LOAD (loadavg, ncpu, mem). Omit for a host-derived default (clamped). ' \
27
+ 'LOAD (loadavg, ncpu, mem). Omit for a host-derived default. ' \
28
+ 'Explicit timeout is honored up to 10800s (3 hours) for any payload. On ' \
29
+ 'timeout, keep the same payload and retry with timeout += 180 ' \
30
+ 'until the 3-hour budget is gone; then rewrite (max 10 mutations/task). ' \
28
31
  'Returns captured stdout plus the inspected value of the last expression.',
29
32
  parameters: {
30
33
  type: 'object',
@@ -32,7 +35,7 @@ PWN::AI::Agent::Registry.register(
32
35
  code: { type: 'string', description: 'Ruby source to evaluate.' },
33
36
  timeout: {
34
37
  type: 'integer',
35
- description: 'Conservative seconds this eval should take given HOST LOAD. Omit for host-derived default. Clamped 1..90.'
38
+ description: 'Conservative seconds this eval should take given HOST LOAD. Omit for a host-derived default. Explicit values honored 1..10800 (3 hours). On timeout keep the same payload and timeout += 180; rewrite only after the 3-hour budget (max 10 mutations/task).'
36
39
  }
37
40
  },
38
41
  required: %w[code]
@@ -61,7 +64,7 @@ PWN::AI::Agent::Registry.register(
61
64
  old_stdout = $stdout
62
65
  buf = StringIO.new
63
66
  $stdout = buf
64
- timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :eval)
67
+ timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :eval, payload: code)
65
68
  begin
66
69
  # rubocop:disable Security/Eval
67
70
  # INTENTIONAL: this IS the pwn-ai → PWN bridge
@@ -89,17 +92,12 @@ PWN::AI::Agent::Registry.register(
89
92
  # rubocop:enable Security/Eval
90
93
  { stdout: buf.string, value: val.inspect, timeout: timeout }
91
94
  rescue Timeout::Error
92
- lesson = PWN::AI::Agent::ToolGuard.timeout_lesson(
95
+ PWN::AI::Agent::ToolGuard.timeout_result(
93
96
  tool: 'pwn_eval',
94
97
  payload: code,
95
- timeout: timeout
98
+ timeout: timeout,
99
+ stdout: buf.string
96
100
  )
97
- {
98
- stdout: buf.string,
99
- error: "timeout after #{timeout}s",
100
- scenario: lesson[:scenario],
101
- hint: lesson[:hint]
102
- }
103
101
  rescue ScriptError, StandardError => e
104
102
  {
105
103
  stdout: buf.string,
@@ -23,14 +23,17 @@ PWN::AI::Agent::Registry.register(
23
23
  'stdout/stderr/exit code. Use for OS-level work: nmap, curl, ' \
24
24
  'ls, git, file inspection, anything not in the PWN:: namespace. ' \
25
25
  'Pass timeout as a conservative integer seconds estimate given ' \
26
- 'HOST LOAD (loadavg, ncpu, mem). Omit for a host-derived default (clamped).',
26
+ 'HOST LOAD (loadavg, ncpu, mem). Omit for a host-derived default. ' \
27
+ 'Explicit timeout is honored up to 10800s (3 hours) for any payload. On ' \
28
+ 'timeout, keep the same payload and retry with timeout += 180 ' \
29
+ 'until the 3-hour budget is gone; then rewrite (max 10 mutations/task).',
27
30
  parameters: {
28
31
  type: 'object',
29
32
  properties: {
30
33
  command: { type: 'string', description: 'The exact shell command to run.' },
31
34
  timeout: {
32
35
  type: 'integer',
33
- description: 'Conservative seconds this command should take given HOST LOAD. Omit for host-derived default. Clamped 1..180.'
36
+ description: 'Conservative seconds this command should take given HOST LOAD. Omit for a host-derived default. Explicit values honored 1..10800 (3 hours). On timeout keep the same payload and timeout += 180; rewrite only after the 3-hour budget (max 10 mutations/task).'
34
37
  }
35
38
  },
36
39
  required: %w[command]
@@ -52,7 +55,7 @@ PWN::AI::Agent::Registry.register(
52
55
  .gsub(/\\\r?\n/, ' ')
53
56
  .gsub(/\\+\s*\z/, '')
54
57
  .strip
55
- timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :shell)
58
+ timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :shell, payload: cmd)
56
59
  if cmd.empty? || PWN::AI::Agent::ToolGuard.placeholder?(text: cmd)
57
60
  return PWN::AI::Agent::ToolGuard.invalid_payload(
58
61
  hint: 'command is required (string). Do not send ..., {...}, {…}, or empty. ' \
@@ -27,6 +27,15 @@ module PWN
27
27
  DEFAULT_MAX_ATTEMPTS
28
28
  end
29
29
 
30
+ public_class_method def self.retryable?(opts = {})
31
+ err = opts[:error]
32
+ msg = err.respond_to?(:message) ? err.message.to_s : err.to_s
33
+ msg = opts[:message].to_s if msg.empty?
34
+ msg.match?(/HTTP 50[234]|Gateway Time-out|stream absolute timeout|ReadTimeout|Net::ReadTimeout|Timed out reading/i)
35
+ rescue StandardError
36
+ false
37
+ end
38
+
30
39
  public_class_method def self.retry_after_s(opts = {})
31
40
  retry_count = [opts[:retry_count].to_i, 1].max
32
41
  retry_after = 0
data/lib/pwn/ai/ollama.rb CHANGED
@@ -198,6 +198,21 @@ module PWN
198
198
  retry if retry_count < max_attempts
199
199
 
200
200
  nil
201
+ rescue StandardError => e
202
+ if PWN::AI::HttpRetry.retryable?(error: e) && retry_count + 1 < max_attempts
203
+ retry_count += 1
204
+ unless opts[:quiet]
205
+ PWN::AI::HttpRetry.report_event(
206
+ label: 'ollama', which_self: self, quiet: opts[:quiet],
207
+ http_method: http_method, rest_call: rest_call,
208
+ extra: "retryable attempt=#{retry_count}/#{max_attempts}",
209
+ error: e
210
+ )
211
+ end
212
+ sleep(PWN::AI::HttpRetry.retry_after_s(retry_count: retry_count) + rand(0.3..2.0))
213
+ retry
214
+ end
215
+ raise e
201
216
  end
202
217
  rescue RestClient::ExceptionWithResponse => e
203
218
  body = begin
@@ -14,19 +14,53 @@ module PWN
14
14
  # nmap.connect_scan = true
15
15
  # nmap.service_scan = true
16
16
  # nmap.verbose = true
17
- # nmap.ports = [1..1024,1337]
17
+ # nmap.ports = [1..1024, 1337]
18
18
  # nmap.targets = '127.0.0.1'
19
- # nmap.xml = '/tmp/nmap_port_scan_res.xml'
19
+ # nmap.xml = '/tmp/nmap_port_scan_res.xml' # alias of output_xml
20
20
  # end
21
+ #
22
+ # PWN::Plugins::NmapIt.port_scan(
23
+ # targets: '127.0.0.1',
24
+ # ports: '1-65535',
25
+ # connect_scan: true,
26
+ # service_scan: true,
27
+ # script: 'vuln,safe',
28
+ # xml: '/tmp/nmap_port_scan_res.xml'
29
+ # )
21
30
 
22
- public_class_method def self.port_scan
31
+ public_class_method def self.port_scan(opts = {})
23
32
  Nmap::Command.sudo do |nmap|
24
- yield(nmap)
33
+ apply_port_scan_opts(nmap: nmap, opts: opts)
34
+ yield(nmap_compat(nmap: nmap)) if block_given?
25
35
  end
26
36
  rescue StandardError => e
27
37
  raise e
28
38
  end
29
39
 
40
+ private_class_method def self.apply_port_scan_opts(opts = {})
41
+ nmap = opts[:nmap]
42
+ args = opts[:opts]
43
+ return nmap unless args.is_a?(Hash)
44
+
45
+ nmap.targets = args[:targets] || args[:target] if args[:targets] || args[:target]
46
+ nmap.ports = args[:ports] if args.key?(:ports)
47
+ xml = args[:xml] || args[:output_xml]
48
+ nmap.output_xml = xml if xml
49
+ nmap.connect_scan = args[:connect_scan] if args.key?(:connect_scan)
50
+ nmap.service_scan = args[:service_scan] || args[:service_detection] if args.key?(:service_scan) || args.key?(:service_detection)
51
+ nmap.verbose = args[:verbose] if args.key?(:verbose)
52
+ scripts = args[:script] || args[:scripts] || args[:vuln_scripts]
53
+ nmap.script = scripts if scripts
54
+ nmap
55
+ end
56
+
57
+ private_class_method def self.nmap_compat(opts = {})
58
+ nmap = opts[:nmap]
59
+ nmap.define_singleton_method(:xml=) { |path| self.output_xml = path }
60
+ nmap.define_singleton_method(:xml) { output_xml }
61
+ nmap
62
+ end
63
+
30
64
  # Supported Method Parameters::
31
65
  # PWN::Plugins::NmapIt.parse_xml_results(:xml_file => 'required - path to nmap xml results') do |xml|
32
66
  # puts xml.public_methods
@@ -103,6 +137,15 @@ module PWN
103
137
  nmap.xml = '/tmp/nmap_port_scan_res.xml'
104
138
  end
105
139
 
140
+ #{self}.port_scan(
141
+ targets: '127.0.0.1',
142
+ ports: '1-65535',
143
+ connect_scan: true,
144
+ service_scan: true,
145
+ script: 'vuln,safe',
146
+ xml: '/tmp/nmap_port_scan_res.xml'
147
+ )
148
+
106
149
  #{self}.parse_xml_results(:xml_file => 'required - path to nmap xml results') do |xml|
107
150
  xml.each_host do |host|
108
151
  puts host.ip
@@ -1284,8 +1284,9 @@ module PWN
1284
1284
  raise
1285
1285
  rescue StandardError => e
1286
1286
  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!)
1287
- warn "[pwn-ai] native agent loop failed (#{e.class}: #{e.message}\n#{e.backtrace}); " \
1288
- 'falling back to legacy regex-ReAct.'
1287
+ warn "[pwn-ai] native agent loop failed (#{e.class}: #{e.message.to_s.split("\n").first})"
1288
+ request.replace('nil')
1289
+ next
1289
1290
  ensure
1290
1291
  PWN::Plugins::REPL.ready_tty!
1291
1292
  end
@@ -1557,7 +1558,7 @@ module PWN
1557
1558
  end
1558
1559
 
1559
1560
  PWN_AI_SLASH_COMMANDS = %w[
1560
- /back /cron /debug /delegate /help /memory /sessions /skills
1561
+ /back /cron /debug /delegate /help /memory /model /sessions /skills
1561
1562
  ].freeze
1562
1563
 
1563
1564
  PWN_AI_SLASH_SUBCOMMANDS = {
@@ -1566,6 +1567,7 @@ module PWN
1566
1567
  '/delegate' => [],
1567
1568
  '/help' => [],
1568
1569
  '/memory' => %w[list recall remember forget clear],
1570
+ '/model' => %w[list],
1569
1571
  '/sessions' => %w[list resume delete stats],
1570
1572
  '/skills' => %w[list recall]
1571
1573
  }.freeze
@@ -1618,6 +1620,20 @@ module PWN
1618
1620
 
1619
1621
  cmd = tokens.first
1620
1622
  sub_prefix = tokens.last.to_s
1623
+ if cmd == '/model'
1624
+ engines = pwn_ai_engines
1625
+ if tokens.length == 2
1626
+ pool = (%w[list] + engines)
1627
+ return pool.select { |s| sub_prefix.empty? || s.start_with?(sub_prefix) }
1628
+ end
1629
+ return %w[llms].select { |s| sub_prefix.empty? || s.start_with?(sub_prefix) } if tokens.length == 3 && tokens[1] == 'list'
1630
+
1631
+ if tokens.length >= 3
1632
+ current = pwn_ai_engine_model(engine: tokens[1]).to_s
1633
+ hits = [current].reject(&:empty?).select { |s| sub_prefix.empty? || s.start_with?(sub_prefix) }
1634
+ return hits unless hits.empty?
1635
+ end
1636
+ end
1621
1637
  subs = Array(PWN_AI_SLASH_SUBCOMMANDS[cmd])
1622
1638
  hits = subs.select { |s| sub_prefix.empty? || s.start_with?(sub_prefix) }
1623
1639
  hits = [target] if hits.empty? && !target.empty?
@@ -1735,6 +1751,8 @@ module PWN
1735
1751
  when '/delegate'
1736
1752
  puts "[*] Delegating: #{args.join(' ')}"
1737
1753
  puts ' Use agent_list / agent_debate from pwn-ai, or pwn-ai-delegate in the pwn REPL.'
1754
+ when '/model'
1755
+ pwn_ai_run_model(args: args)
1738
1756
  end
1739
1757
  true
1740
1758
  rescue StandardError => e
@@ -1742,6 +1760,171 @@ module PWN
1742
1760
  true
1743
1761
  end
1744
1762
 
1763
+ public_class_method def self.pwn_ai_engines(opts = {})
1764
+ return [] unless opts.is_a?(Hash)
1765
+
1766
+ tmpl = {}
1767
+ tmpl = PWN::Config.env_template[:ai] if defined?(PWN::Config) && PWN::Config.respond_to?(:env_template)
1768
+ keys = tmpl.select { |_k, v| v.is_a?(Hash) && v.key?(:model) }.keys.map(&:to_s)
1769
+ if defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env[:ai].is_a?(Hash)
1770
+ PWN::Env[:ai].each do |k, v|
1771
+ next unless v.is_a?(Hash)
1772
+ next if %i[agent driver_opts].include?(k.to_sym)
1773
+
1774
+ keys << k.to_s if v.key?(:model) || v.key?(:key) || v.key?(:base_uri)
1775
+ end
1776
+ end
1777
+ keys.uniq.sort
1778
+ end
1779
+
1780
+ public_class_method def self.pwn_ai_provider_class(opts = {})
1781
+ engine = opts[:engine].to_s.downcase
1782
+ map = {
1783
+ 'anthropic' => 'Anthropic',
1784
+ 'gemini' => 'Gemini',
1785
+ 'grok' => 'Grok',
1786
+ 'ollama' => 'Ollama',
1787
+ 'openai' => 'OpenAI',
1788
+ 'openwebui' => 'OpenWebUI'
1789
+ }
1790
+ name = map[engine]
1791
+ return nil if name.nil? || !defined?(PWN::AI) || !PWN::AI.const_defined?(name)
1792
+
1793
+ PWN::AI.const_get(name)
1794
+ end
1795
+
1796
+ public_class_method def self.pwn_ai_model_ids(opts = {})
1797
+ raw = opts[:models]
1798
+ rows = case raw
1799
+ when Array then raw
1800
+ when Hash then raw[:data] || raw[:models] || raw['data'] || raw['models'] || []
1801
+ else []
1802
+ end
1803
+ Array(rows).filter_map do |row|
1804
+ if row.is_a?(Hash)
1805
+ row[:id] || row['id'] || row[:name] || row['name'] || row[:model] || row['model']
1806
+ else
1807
+ row.to_s
1808
+ end
1809
+ end.map(&:to_s).reject(&:empty?).uniq
1810
+ end
1811
+
1812
+ public_class_method def self.pwn_ai_list_llms(opts = {})
1813
+ engine = opts[:engine].to_s
1814
+ engine = PWN::Env.dig(:ai, :active).to_s if engine.empty? && defined?(PWN::Env)
1815
+ raise 'no active engine — /model <engine> first' if engine.empty?
1816
+
1817
+ klass = pwn_ai_provider_class(engine: engine)
1818
+ raise "#{engine} has no PWN::AI provider with get_models" unless klass.respond_to?(:get_models)
1819
+
1820
+ ids = pwn_ai_model_ids(models: klass.get_models)
1821
+ puts "[*] #{engine} llms (#{ids.length})"
1822
+ ids.each { |id| puts id }
1823
+ ids
1824
+ end
1825
+
1826
+ public_class_method def self.pwn_ai_engine_model(opts = {})
1827
+ engine = opts[:engine].to_s.downcase.to_sym
1828
+ return '' if engine.empty?
1829
+ return '' unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
1830
+
1831
+ PWN::Env.dig(:ai, engine, :model).to_s
1832
+ end
1833
+
1834
+ public_class_method def self.pwn_ai_run_model(opts = {})
1835
+ args = Array(opts[:args]).map(&:to_s)
1836
+ engines = pwn_ai_engines
1837
+ current = defined?(PWN::Env) && PWN::Env.is_a?(Hash) ? PWN::Env.dig(:ai, :active).to_s : ''
1838
+ current_model = pwn_ai_engine_model(engine: current)
1839
+ sub = args[0].to_s
1840
+ if sub.empty? || %w[show status].include?(sub)
1841
+ msg = "active=#{current.empty? ? '(none)' : current} model=#{current_model.empty? ? '(unset)' : current_model}"
1842
+ puts "[*] #{msg}"
1843
+ return msg
1844
+ end
1845
+ if %w[list help].include?(sub)
1846
+ return pwn_ai_list_llms(engine: current) if args[1].to_s == 'llms'
1847
+
1848
+ puts 'pwn-ai /model — switch provider and model in this session'
1849
+ puts " current: #{current} #{current_model}"
1850
+ puts ' usage: /model [list] | /model list llms | /model <engine> [model] | /model <model>'
1851
+ engines.each do |eng|
1852
+ mark = eng == current ? '*' : ' '
1853
+ puts " #{mark} #{eng} #{pwn_ai_engine_model(engine: eng)}"
1854
+ end
1855
+ return engines
1856
+ end
1857
+
1858
+ engine = nil
1859
+ model = nil
1860
+ if engines.include?(sub)
1861
+ engine = sub
1862
+ model = args[1..].join(' ')
1863
+ model = nil if model.strip.empty?
1864
+ else
1865
+ engine = current
1866
+ model = args.join(' ')
1867
+ end
1868
+ raise "no active engine — /model <engine> first (#{engines.join(', ')})" if engine.to_s.empty?
1869
+ raise "unknown engine #{engine.inspect} — try: #{engines.join(', ')}" unless engines.include?(engine.to_s)
1870
+
1871
+ PWN::Env[:ai] ||= {}
1872
+ PWN::Env[:ai][engine.to_sym] ||= {}
1873
+ PWN::Env[:ai][:active] = engine.to_s
1874
+ PWN::Env[:ai][engine.to_sym][:model] = model unless model.to_s.strip.empty?
1875
+ persisted = persist_ai_selection(engine: engine, model: PWN::Env[:ai][engine.to_sym][:model])
1876
+ shown = PWN::Env[:ai][engine.to_sym][:model]
1877
+ msg = "active=#{engine} model=#{shown.to_s.empty? ? '(unset)' : shown}"
1878
+ msg = "#{msg} (session only)" unless persisted
1879
+ puts "[*] #{msg}"
1880
+ msg
1881
+ end
1882
+
1883
+ public_class_method def self.persist_ai_selection(opts = {})
1884
+ engine = opts[:engine].to_s
1885
+ model = opts[:model]
1886
+ return false if engine.empty?
1887
+
1888
+ env_path = nil
1889
+ dec_path = nil
1890
+ if defined?(PWN::Env) && PWN::Env.is_a?(Hash)
1891
+ env_path = PWN::Env.dig(:driver_opts, :pwn_env_path)
1892
+ dec_path = PWN::Env.dig(:driver_opts, :pwn_dec_path)
1893
+ end
1894
+ env_path = env_path.to_s.strip
1895
+ env_path = File.join(Dir.home, '.pwn', 'pwn.yaml') if env_path.empty?
1896
+ dec_path = dec_path.to_s.strip
1897
+ dec_path = "#{env_path}.decryptor" if dec_path.empty?
1898
+ return false unless File.exist?(env_path) && File.exist?(dec_path) && File.readable?(dec_path)
1899
+
1900
+ decryptor = YAML.load_file(dec_path, symbolize_names: true)
1901
+ key = decryptor.is_a?(Hash) ? decryptor[:key] : nil
1902
+ iv = decryptor.is_a?(Hash) ? decryptor[:iv] : nil
1903
+ return false if key.to_s.strip.empty? || iv.to_s.strip.empty?
1904
+
1905
+ PWN::Plugins::Vault.decrypt(file: env_path, key: key, iv: iv)
1906
+ begin
1907
+ cfg = YAML.load_file(env_path, symbolize_names: true)
1908
+ cfg = {} unless cfg.is_a?(Hash)
1909
+ cfg[:ai] = {} unless cfg[:ai].is_a?(Hash)
1910
+ cfg[:ai][:active] = engine
1911
+ unless model.to_s.strip.empty?
1912
+ slot = engine.to_sym
1913
+ cfg[:ai][slot] = {} unless cfg[:ai][slot].is_a?(Hash)
1914
+ cfg[:ai][slot][:model] = model
1915
+ end
1916
+ yaml_env = YAML.dump(cfg).gsub(/^(\s*):/, '\1')
1917
+ File.write(env_path, yaml_env)
1918
+ File.chmod(0o600, env_path)
1919
+ ensure
1920
+ PWN::Plugins::Vault.encrypt(file: env_path, key: key, iv: iv)
1921
+ end
1922
+ true
1923
+ rescue StandardError => e
1924
+ warn "[pwn-ai] /model persist skipped: #{e.class}: #{e.message}"
1925
+ false
1926
+ end
1927
+
1745
1928
  public_class_method def self.pwn_ai_run_cron(opts = {})
1746
1929
  args = Array(opts[:args])
1747
1930
  sub = args[0] || '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.696'
4
+ VERSION = '0.5.698'
5
5
  end
@@ -424,15 +424,23 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
424
424
  expect(src).to match(/note_exception!/)
425
425
  expect(src).to match(/rescue StandardError/)
426
426
  expect(src).to include("debug_progress(msg: 'engine returned no message')")
427
+ expect(src).to match(/engine_transient|retryable\?/)
427
428
  expect(src).not_to match(/dispatch #\{name\} ok_len=/)
428
429
  end
429
430
 
430
- it 'records a reconstruct-first timeout mistake instead of treating success:true as ok' do
431
+ it 'keeps Loop.run going after a transient engine 504' do
432
+ src = File.read(described_class.method(:run).source_location.first)
433
+ expect(src).to match(/engine_transient\?|HttpRetry\.retryable\?/)
434
+ expect(src).to match(/engine hop failed|engine_blip/)
435
+ end
436
+
437
+ it 'records a timeout increment mistake instead of treating success:true as ok' do
431
438
  tmp = Dir.mktmpdir
432
439
  stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(tmp, 'mistakes.json'))
440
+ PWN::AI::Agent::ToolGuard.reset_timeout_budget! if PWN::AI::Agent::ToolGuard.respond_to?(:reset_timeout_budget!)
433
441
  raw = JSON.generate(
434
442
  success: true,
435
- result: { stdout: '', error: 'timeout after 20s', hint: 'x', scenario: 'construction' }
443
+ result: { stdout: '', error: 'timeout after 20s', hint: 'x', scenario: 'deadline' }
436
444
  )
437
445
  tele = described_class.send(
438
446
  :record_metrics,
@@ -443,7 +451,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
443
451
  )
444
452
  expect(tele[:ok]).to eq false
445
453
  expect(tele[:mistake]).to be_a(Hash)
446
- expect(tele[:mistake][:error].to_s).to match(/reconstruct/)
454
+ expect(tele[:mistake][:error].to_s).to match(/timeout \+= 180|too short/i)
447
455
  expect(tele[:mistake][:shape].to_s).to eq('timeout')
448
456
  end
449
457
 
@@ -68,40 +68,101 @@ describe PWN::AI::Agent::ToolGuard do
68
68
  expect(snap[:mem_avail_mb].to_i).to be >= 0
69
69
  end
70
70
 
71
- it 'clamps an explicit timeout and derives a default when omitted' do
71
+ it 'honors any explicit timeout up to 3 hours and derives a default when omitted' do
72
72
  expect(described_class.deadline_s(timeout: 1, kind: :eval)).to eq(1)
73
- expect(described_class.deadline_s(timeout: 9_999, kind: :eval)).to eq(90)
74
- expect(described_class.deadline_s(timeout: 9_999, kind: :shell)).to eq(180)
73
+ expect(described_class.deadline_s(timeout: 300, kind: :eval)).to eq(300)
74
+ expect(described_class.deadline_s(timeout: 3_600, kind: :shell)).to eq(3_600)
75
+ expect(described_class.deadline_s(timeout: 99_999, kind: :eval)).to eq(10_800)
75
76
  omitted = described_class.deadline_s(kind: :eval)
76
77
  expect(omitted).to be_between(8, 90)
77
78
  end
79
+
80
+ it 'does not sniff payloads for named tools' do
81
+ src = File.read(described_class.method(:deadline_s).source_location.first)
82
+ expect(src).not_to match(/\bnmap\b/i)
83
+ expect(src).not_to match(/\bsqlmap\b/i)
84
+ expect(src).not_to match(/\bhydra\b/i)
85
+ expect(described_class).not_to respond_to(:long_work?)
86
+ end
87
+ end
88
+
89
+ describe '.next_timeout' do
90
+ it 'adds 180 seconds until the 3-hour cap' do
91
+ expect(described_class.next_timeout(timeout: 60)).to eq(240)
92
+ expect(described_class.next_timeout(timeout: 180)).to eq(360)
93
+ expect(described_class.next_timeout(timeout: 10_800)).to eq(10_800)
94
+ end
78
95
  end
79
96
 
80
97
  describe '.timeout_lesson' do
81
- it 'prefers reconstruct-the-payload before raising timeout' do
98
+ before do
99
+ described_class.reset_timeout_budget!
100
+ end
101
+
102
+ it 'increments timeout on the same payload before rewriting' do
82
103
  tmp = Dir.mktmpdir
83
104
  stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(tmp, 'mistakes.json'))
84
- first = described_class.timeout_lesson(tool: 'pwn_eval', payload: 'sleep 3', timeout: 1)
85
- expect(first[:scenario]).to eq(:construction)
86
- expect(first[:hint]).to match(/reconstruct|generated differently|improperly/i)
87
- expect(first[:hint]).to match(/do not first raise timeout/i)
88
- expect(first[:error]).to match(/reconstruct/)
105
+ first = described_class.timeout_lesson(
106
+ tool: 'pwn_eval',
107
+ payload: 'sleep 3',
108
+ timeout: 180,
109
+ task: 't1'
110
+ )
111
+ expect(first[:scenario]).to eq(:deadline)
112
+ expect(first[:hint]).to match(/timeout \+= 180|next_timeout/i)
113
+ expect(first[:hint]).not_to match(/reconstruct/i)
114
+ expect(described_class.next_timeout(timeout: 180, spent: 180)).to eq(360)
89
115
  end
90
116
 
91
- it 'switches to deadline-too-short only after a prior timeout mistake' do
117
+ it 'rewrites the payload only after the 3-hour budget is exhausted' do
92
118
  tmp = Dir.mktmpdir
93
119
  stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(tmp, 'mistakes.json'))
94
- PWN::AI::Agent::Mistakes.record(
120
+ described_class.note_timeout!(
121
+ tool: 'shell',
122
+ payload: 'sleep 3',
123
+ timeout: 10_800,
124
+ task: 't1'
125
+ )
126
+ lesson = described_class.timeout_lesson(
127
+ tool: 'shell',
128
+ payload: 'sleep 3',
129
+ timeout: 10_800,
130
+ task: 't1'
131
+ )
132
+ expect(lesson[:scenario]).to eq(:construction)
133
+ expect(lesson[:hint]).to match(/3-hour budget|Generate different/i)
134
+ expect(described_class.mutation_count(task: 't1')).to eq(1)
135
+ end
136
+
137
+ it 'stops after 10 mutations on the same task' do
138
+ 10.times do |i|
139
+ described_class.note_timeout!(
140
+ tool: 'shell',
141
+ payload: "payload-#{i}",
142
+ timeout: 10_800,
143
+ task: 't1'
144
+ )
145
+ end
146
+ last = described_class.timeout_lesson(
147
+ tool: 'shell',
148
+ payload: 'payload-9',
149
+ timeout: 10_800,
150
+ task: 't1'
151
+ )
152
+ expect(described_class.mutation_count(task: 't1')).to eq(10)
153
+ expect(last[:scenario]).to eq(:exhausted)
154
+ expect(last[:hint]).to match(/10 mutations|mutation cap/i)
155
+ end
156
+
157
+ it 'includes next_timeout on a mid-budget timeout result' do
158
+ out = described_class.timeout_result(
95
159
  tool: 'shell',
96
- error: 'shell timeout: reconstruct payload to same goal before raising timeout',
97
- args: 'sleep 3',
98
- source: :tool,
99
- shape: :timeout
160
+ payload: 'sleep 3',
161
+ timeout: 180,
162
+ task: 't1'
100
163
  )
101
- second = described_class.timeout_lesson(tool: 'shell', payload: 'sleep 3', timeout: 1)
102
- expect(second[:scenario]).to eq(:deadline)
103
- expect(second[:hint]).to match(/timeout was too short|deadline too short|raise a conservative/i)
104
- expect(second[:error]).to match(/too short/)
164
+ expect(out[:scenario]).to eq(:deadline)
165
+ expect(out[:next_timeout]).to eq(360)
105
166
  end
106
167
  end
107
168
  end
@@ -49,8 +49,8 @@ describe 'PWN::AI::Agent::Tools ruby_eval' do
49
49
  result = entry.handler.call(code: 'sleep 3', timeout: 1)
50
50
  expect(Time.now - t0).to be < 2.5
51
51
  expect(result[:error].to_s).to match(/timeout after 1s/)
52
- expect(result[:hint].to_s).to match(/reconstruct|generated differently|improperly/i)
53
- expect(result[:scenario].to_s).to eq('construction')
52
+ expect(result[:hint].to_s).to match(/timeout \+= 180|next_timeout|same .*payload/i)
53
+ expect(result[:scenario].to_s).to eq('deadline')
54
54
  end
55
55
 
56
56
  it 'Dispatch.call JSON-wraps syntax errors instead of raising' do
@@ -60,12 +60,12 @@ describe 'PWN::AI::Agent::Tools shell' do
60
60
  expect(r[:exit]).to eq(0)
61
61
  end
62
62
 
63
- it 'on timeout prefers reconstruct-the-command before raising timeout' do
63
+ it 'on timeout keeps the same command and increments timeout' do
64
64
  tmp = Dir.mktmpdir
65
65
  stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', File.join(tmp, 'mistakes.json'))
66
66
  r = handler.call(command: 'sleep 3', timeout: 1)
67
67
  expect(r[:error].to_s).to match(/timeout after 1s/)
68
- expect(r[:hint].to_s).to match(/reconstruct|generated differently|improperly/i)
69
- expect(r[:scenario].to_s).to eq('construction')
68
+ expect(r[:hint].to_s).to match(/timeout \+= 180|next_timeout|same .*payload/i)
69
+ expect(r[:scenario].to_s).to eq('deadline')
70
70
  end
71
71
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'stringio'
5
+ require 'rest-client'
5
6
 
6
7
  describe PWN::AI::HttpRetry do
7
8
  it 'defaults timeout to 180 and max_attempts to 5' do
@@ -14,6 +15,12 @@ describe PWN::AI::HttpRetry do
14
15
  expect(described_class.max_attempts(max_attempts: 3)).to eq(3)
15
16
  end
16
17
 
18
+ it 'treats gateway 504 and stream absolute timeouts as retryable' do
19
+ expect(described_class.retryable?(error: 'ERROR: Ollama HTTP 504 for api/chat: Gateway Time-out')).to eq(true)
20
+ expect(described_class.retryable?(error: 'ERROR: Ollama stream absolute timeout after 20s for api/chat')).to eq(true)
21
+ expect(described_class.retryable?(error: '400 Bad Request')).to eq(false)
22
+ end
23
+
17
24
  it 'tees emit lines into the open debug request log' do
18
25
  path = PWN::Plugins::Log.start_debug(
19
26
  tee: StringIO.new,
@@ -50,6 +57,7 @@ describe PWN::AI::HttpRetry do
50
57
  expect(src).not_to match(/timeout \|\|= 900/)
51
58
  expect(src).to match(/Exceptions::Timeout/)
52
59
  expect(src).to match(/HttpRetry\.report_event/)
60
+ expect(src).to match(/retryable\?|HTTP 50/) if name == 'PWN::AI::Ollama'
53
61
  end
54
62
  end
55
63
  end
@@ -12,4 +12,11 @@ describe PWN::Plugins::NmapIt do
12
12
  help_response = PWN::Plugins::NmapIt
13
13
  expect(help_response).to respond_to :help
14
14
  end
15
+
16
+ it 'port_scan accepts opts = {} and aliases xml to output_xml' do
17
+ expect(described_class.method(:port_scan).parameters).to include(%i[opt opts])
18
+ src = File.read(described_class.method(:port_scan).source_location.first)
19
+ expect(src).to include('opts[')
20
+ expect(src).to include('output_xml')
21
+ end
15
22
  end
@@ -174,4 +174,14 @@ describe 'pwn-ai debug final answer placement' do
174
174
  expect(hook).to include('→ pwn-ai →')
175
175
  expect(hook).to include('→ result')
176
176
  end
177
+
178
+ it 'does not fall through to regex-ReAct after a native Loop.run error' do
179
+ native_start = hook.index('final = PWN::AI::Agent::Loop.run')
180
+ rescue_at = hook.index('rescue StandardError', native_start)
181
+ expect(rescue_at).not_to be_nil
182
+ chunk = hook[rescue_at, 900]
183
+ expect(chunk).to match(/request\.replace\('nil'\)/)
184
+ expect(chunk).to match(/\bnext\b/)
185
+ expect(chunk).not_to include('legacy regex-ReAct')
186
+ end
177
187
  end
@@ -67,11 +67,15 @@ describe PWN::Plugins::REPL do
67
67
  hits = described_class.pwn_ai_complete(target: '/sk', line: '/sk')
68
68
  expect(hits).to include('/skills')
69
69
  hits = described_class.pwn_ai_complete(target: '/', line: '/')
70
- %w[/cron /skills /sessions /memory /debug /back /help].each do |cmd|
70
+ %w[/cron /skills /sessions /memory /debug /back /help /model].each do |cmd|
71
71
  expect(hits).to include(cmd)
72
72
  end
73
73
  hits = described_class.pwn_ai_complete(target: 'li', line: '/cron li')
74
74
  expect(hits).to include('list')
75
+ hits = described_class.pwn_ai_complete(target: 'li', line: '/model li')
76
+ expect(hits).to include('list')
77
+ hits = described_class.pwn_ai_complete(target: 'll', line: '/model list ll')
78
+ expect(hits).to include('llms')
75
79
  end
76
80
 
77
81
  it 'completes host-native paths when slash is not the first character' do
@@ -101,5 +105,37 @@ describe PWN::Plugins::REPL do
101
105
  expect(hook).to match(/pwn_ai_dispatch_slash!/)
102
106
  expect(described_class).to respond_to(:pwn_ai_dispatch_slash!)
103
107
  end
108
+
109
+ it 'switches the live engine and model via /model without Loop.run' do
110
+ expect(described_class).to respond_to(:pwn_ai_run_model)
111
+ PWN::Env[:ai] ||= {}
112
+ PWN::Env[:ai][:grok] ||= {}
113
+ prev_active = PWN::Env[:ai][:active]
114
+ prev_model = PWN::Env[:ai][:grok][:model]
115
+ allow(described_class).to receive(:persist_ai_selection).and_return(false)
116
+ out = described_class.pwn_ai_run_model(args: %w[grok pwn-ai-test-model])
117
+ expect(PWN::Env[:ai][:active].to_s).to eq('grok')
118
+ expect(PWN::Env[:ai][:grok][:model]).to eq('pwn-ai-test-model')
119
+ expect(out.to_s).to match(/grok/i)
120
+ ensure
121
+ if PWN::Env.is_a?(Hash) && PWN::Env[:ai].is_a?(Hash)
122
+ PWN::Env[:ai][:active] = prev_active if defined?(prev_active)
123
+ PWN::Env[:ai][:grok][:model] = prev_model if defined?(prev_model) && PWN::Env[:ai][:grok].is_a?(Hash)
124
+ end
125
+ end
126
+
127
+ it 'lists llm ids from the active provider via /model list llms' do
128
+ expect(described_class).to respond_to(:pwn_ai_list_llms)
129
+ PWN::Env[:ai] ||= {}
130
+ prev_active = PWN::Env[:ai][:active]
131
+ PWN::Env[:ai][:active] = 'grok'
132
+ allow(PWN::AI::Grok).to receive(:get_models).and_return(
133
+ [{ id: 'grok-test-a' }, { id: 'grok-test-b' }]
134
+ )
135
+ ids = described_class.pwn_ai_run_model(args: %w[list llms])
136
+ expect(ids).to eq(%w[grok-test-a grok-test-b])
137
+ ensure
138
+ PWN::Env[:ai][:active] = prev_active if PWN::Env.is_a?(Hash) && PWN::Env[:ai].is_a?(Hash)
139
+ end
104
140
  end
105
141
  end
@@ -339,6 +339,7 @@
339
339
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.degrade_text_only Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.degrade_text_only`: "}]}
340
340
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.dispatch_fail_n Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.dispatch_fail_n`: "}]}
341
341
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.emit_task_summary Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.emit_task_summary`: "}]}
342
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.engine_transient? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.engine_transient?`: "}]}
342
343
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.escalate Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.escalate`: "}]}
343
344
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.evidence_enough_to_finalize? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.evidence_enough_to_finalize?`: "}]}
344
345
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.expose_current_session Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.expose_current_session`: "}]}
@@ -383,6 +384,7 @@
383
384
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.task_summary_flush! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.task_summary_flush!`: "}]}
384
385
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.task_summary_plan! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.task_summary_plan!`: "}]}
385
386
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.task_summary_record! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.task_summary_record!`: "}]}
387
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.timeout_task Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.timeout_task`: "}]}
386
388
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.tool_effects Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.tool_effects`: "}]}
387
389
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.world_knowledge? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.world_knowledge?`: "}]}
388
390
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.write_verified? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.write_verified?`: "}]}
@@ -706,18 +708,30 @@
706
708
  {"messages":[{"role":"user","content":"PWN::AI::Agent::TaskSummarizer.why_bit Usage"},{"role":"assistant","content":"`PWN::AI::Agent::TaskSummarizer.why_bit`: "}]}
707
709
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.authors Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.authors`: "}]}
708
710
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.bashism? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.bashism?`: "}]}
711
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.budget_exhausted? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.budget_exhausted?`: "}]}
709
712
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.coerce_args Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.coerce_args`: "}]}
710
713
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.deadline_s Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.deadline_s`: "}]}
711
714
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.help Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.help`: "}]}
712
715
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.host_load Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.host_load`: "}]}
713
716
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.invalid_payload Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.invalid_payload`: "}]}
717
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.mutation_count Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.mutation_count`: "}]}
718
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.next_timeout Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.next_timeout`: "}]}
719
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.note_timeout! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.note_timeout!`: "}]}
720
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.payload_key Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.payload_key`: "}]}
721
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.payload_spent Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.payload_spent`: "}]}
714
722
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.placeholder? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.placeholder?`: "}]}
715
723
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.present? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.present?`: "}]}
724
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.reset_timeout_budget Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.reset_timeout_budget`: "}]}
725
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.reset_timeout_budget! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.reset_timeout_budget!`: "}]}
716
726
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.shell_bash? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.shell_bash?`: "}]}
717
727
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.shell_name Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.shell_name`: "}]}
728
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.task_key Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.task_key`: "}]}
718
729
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.timeout_lesson Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.timeout_lesson`: "}]}
730
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.timeout_mutated Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.timeout_mutated`: "}]}
731
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.timeout_mutations Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.timeout_mutations`: "}]}
719
732
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.timeout_prior_count Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.timeout_prior_count`: "}]}
720
733
  {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.timeout_result Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.timeout_result`: "}]}
734
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::ToolGuard.timeout_spent Usage"},{"role":"assistant","content":"`PWN::AI::Agent::ToolGuard.timeout_spent`: "}]}
721
735
  {"messages":[{"role":"user","content":"PWN::AI::Agent::TransparentBrowser.analyze Usage"},{"role":"assistant","content":"`PWN::AI::Agent::TransparentBrowser.analyze`: Supported Method Parameters\n\nai_analysis = PWN::AI::Agent::TransparentBrowser.analyze(\n\nrequest: 'required - current step in the JavaScript debugging session to analyze',\nsource_to_review: 'required - the block of JavaScript code in which the current step resides'\n\n)\n"}]}
722
736
  {"messages":[{"role":"user","content":"PWN::AI::Agent::TransparentBrowser.authors Usage"},{"role":"assistant","content":"`PWN::AI::Agent::TransparentBrowser.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
723
737
  {"messages":[{"role":"user","content":"PWN::AI::Agent::TransparentBrowser.help Usage"},{"role":"assistant","content":"`PWN::AI::Agent::TransparentBrowser.help`: "}]}
@@ -780,6 +794,7 @@
780
794
  {"messages":[{"role":"user","content":"PWN::AI::HttpRetry.max_attempts Usage"},{"role":"assistant","content":"`PWN::AI::HttpRetry.max_attempts`: "}]}
781
795
  {"messages":[{"role":"user","content":"PWN::AI::HttpRetry.report_event Usage"},{"role":"assistant","content":"`PWN::AI::HttpRetry.report_event`: "}]}
782
796
  {"messages":[{"role":"user","content":"PWN::AI::HttpRetry.retry_after_s Usage"},{"role":"assistant","content":"`PWN::AI::HttpRetry.retry_after_s`: "}]}
797
+ {"messages":[{"role":"user","content":"PWN::AI::HttpRetry.retryable? Usage"},{"role":"assistant","content":"`PWN::AI::HttpRetry.retryable?`: "}]}
783
798
  {"messages":[{"role":"user","content":"PWN::AI::HttpRetry.timeout_s Usage"},{"role":"assistant","content":"`PWN::AI::HttpRetry.timeout_s`: "}]}
784
799
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.assemble_native_chat_stream Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.assemble_native_chat_stream`: "}]}
785
800
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.assemble_ollama_stream Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.assemble_ollama_stream`: "}]}
@@ -2140,11 +2155,13 @@
2140
2155
  {"messages":[{"role":"user","content":"PWN::Plugins::NexposeVulnScan.logout Usage"},{"role":"assistant","content":"`PWN::Plugins::NexposeVulnScan.logout`: Supported Method Parameters\n\nPWN::Plugins::NexposeVulnScan.logout(\n\nnsc_obj: 'required nsc_obj returned from login method'\n\n)\n"}]}
2141
2156
  {"messages":[{"role":"user","content":"PWN::Plugins::NexposeVulnScan.scan_site_by_name Usage"},{"role":"assistant","content":"`PWN::Plugins::NexposeVulnScan.scan_site_by_name`: Supported Method Parameters\n\nPWN::Plugins::NexposeVulnScan.scan_site_by_name(\n\nnsc_obj: 'required nsc_obj returned from login method',\nsite_name: 'required Nexpose site name to scan (case-sensitive),\npoll_interval: 'optional poll interval to check the completion status of the scan (defaults to 3 minutes)\n\n)\n"}]}
2142
2157
  {"messages":[{"role":"user","content":"PWN::Plugins::NexposeVulnScan.update_site_assets Usage"},{"role":"assistant","content":"`PWN::Plugins::NexposeVulnScan.update_site_assets`: Supported Method Parameters\n\nPWN::Plugins::NexposeVulnScan.update_site_assets(\n\nnsc_obj: 'required nsc_obj returned from login method',\nsite_name: 'required Nexpose site name to update (case-sensitive),\nassets: 'required array of hashes containing called :ip => values being IP address (All IPs not included in the :assets parameter will be removed from Nexpose)'\n\n)\n"}]}
2158
+ {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.apply_port_scan_opts Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.apply_port_scan_opts`: "}]}
2143
2159
  {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.authors Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
2144
2160
  {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.diff_xml_results Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.diff_xml_results`: Supported Method Parameters\n\nPWN::Plugins::NmapIt.diff_xml_results(\n\nxml_a: 'required - path to nmap xml results',\nxml_b: 'required - path to nmap xml results',\ndiff: 'required - path to nmap xml results diff'\n\n)\n"}]}
2145
2161
  {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.help Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.help`: "}]}
2162
+ {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.nmap_compat Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.nmap_compat`: "}]}
2146
2163
  {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.parse_xml_results Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.parse_xml_results`: Supported Method Parameters\n\nPWN::Plugins::NmapIt.parse_xml_results(:xml_file => ‘required - path to nmap xml results’) do |xml|\n\nputs xml.public_methods\nxml.each_host do |host|\n puts \"[#{host.ip}]\"\n\n host.scripts.each do |name,output|\n output.each_line { |line| puts \" #{line}\" }\n end\n\n host.each_port do |port|\n puts \" [#{port.number}/#{port.protocol}]\"\n\n port.scripts.each do |name,output|\n puts \" [#{name}]\"\n output.each_line { |line| puts \" #{line}\" }\n end\n end\nend\n\n\nend\n"}]}
2147
- {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.port_scan Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.port_scan`: Supported Method Parameters\n\nPWN::Plugins::NmapIt.port_scan do |nmap|\n\nputs nmap.public_methods\nnmap.connect_scan = true\nnmap.service_scan = true\nnmap.verbose = true\nnmap.ports = [1..1024,1337]\nnmap.targets = '127.0.0.1'\nnmap.xml = '/tmp/nmap_port_scan_res.xml'\n\n\nend\n"}]}
2164
+ {"messages":[{"role":"user","content":"PWN::Plugins::NmapIt.port_scan Usage"},{"role":"assistant","content":"`PWN::Plugins::NmapIt.port_scan`: Supported Method Parameters\n\nPWN::Plugins::NmapIt.port_scan do |nmap|\n\nputs nmap.public_methods\nnmap.connect_scan = true\nnmap.service_scan = true\nnmap.verbose = true\nnmap.ports = [1..1024, 1337]\nnmap.targets = '127.0.0.1'\nnmap.xml = '/tmp/nmap_port_scan_res.xml' # alias of output_xml\n\n\nend\n\nPWN::Plugins::NmapIt.port_scan(\n\ntargets: '127.0.0.1',\nports: '1-65535',\nconnect_scan: true,\nservice_scan: true,\nscript: 'vuln,safe',\nxml: '/tmp/nmap_port_scan_res.xml'\n\n)\n"}]}
2148
2165
  {"messages":[{"role":"user","content":"PWN::Plugins::OAuth2.authors Usage"},{"role":"assistant","content":"`PWN::Plugins::OAuth2.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
2149
2166
  {"messages":[{"role":"user","content":"PWN::Plugins::OAuth2.decode Usage"},{"role":"assistant","content":"`PWN::Plugins::OAuth2.decode`: Supported Method Parameters\n\nPWN::Plugins::OAuth2.decode(\n\noauth2_token: 'required oauth2 token'\n\n)\n"}]}
2150
2167
  {"messages":[{"role":"user","content":"PWN::Plugins::OAuth2.get_value_by_key Usage"},{"role":"assistant","content":"`PWN::Plugins::OAuth2.get_value_by_key`: Supported Method Parameters\n\nPWN::Plugins::OAuth2.get_value_by_key(\n\noauth2_token: 'required oauth2 token',\nkey: 'required oauth2 token key name located within the Base64 encoded token as symbol, e.g. :company_id'\n\n)\n"}]}
@@ -2228,14 +2245,21 @@
2228
2245
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.enable_autocomplete Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.enable_autocomplete`: Supported Method Parameters\n\nPWN::Plugins::REPL.enable_autocomplete(\n\nenabled: 'optional - Boolean (default true). false reverts to single-line cycling.'\n\n)\n\nIRB-style suggest-as-you-type for the pwn REPL.\n\nReplaces Pry’s default input (rb-readline — single-candidate TAB cycling) with Reline and turns on Reline.autocompletion, which renders a live dropdown of candidates below the cursor as you type (the same widget IRB uses). Pry already wires Reline.completion_proc → @pry.complete (Pry::InputCompleter) when input == Reline, so the menu is fed by the full Ruby/PWN object graph: constants (PWN::Plugins::Nm<TAB>), instance methods, local/global variables, and Pry slash-commands.\n\nNavigate with ↑/↓ or TAB, accept with → or ENTER, dismiss with ESC.\n\nScope: this drives the MAIN pwn REPL (Ruby). pwn-ai swaps PWNMultiLineInput onto Reline.readmultiline and installs install_pwn_ai_completer! so TAB is command / path / Ruby menus. pwn-asm keeps opcode input without those menus.\n"}]}
2229
2246
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.help Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.help`: "}]}
2230
2247
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.install_pwn_ai_completer! Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.install_pwn_ai_completer!`: "}]}
2248
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.persist_ai_selection Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.persist_ai_selection`: "}]}
2231
2249
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_complete Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_complete`: Supported Method Parameters\n\nhits = PWN::Plugins::REPL.pwn_ai_complete(\n\ntarget: 'required - token Reline is completing',\nline: 'optional - full line buffer',\npry: 'optional - Pry instance for Ruby completion'\n\n)\n"}]}
2232
2250
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_complete_command Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_complete_command`: "}]}
2233
2251
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_complete_kind Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_complete_kind`: Supported Method Parameters\n\nkind = PWN::Plugins::REPL.pwn_ai_complete_kind(line: ‘optional - current input buffer’)\n\nfirst char is ‘/’ → :command (pwn-ai slash menu)\n\n‘/’ anywhere else → :path (host-native file nav)\n\nelse → :ruby (same Pry::InputCompleter menu as the pwn REPL)\n\n"}]}
2234
2252
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_complete_path Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_complete_path`: "}]}
2235
2253
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_complete_ruby Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_complete_ruby`: "}]}
2236
2254
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_dispatch_slash! Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_dispatch_slash!`: "}]}
2255
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_engine_model Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_engine_model`: "}]}
2256
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_engines Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_engines`: "}]}
2257
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_list_llms Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_list_llms`: "}]}
2258
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_model_ids Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_model_ids`: "}]}
2259
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_provider_class Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_provider_class`: "}]}
2237
2260
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_run_cron Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_run_cron`: "}]}
2238
2261
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_run_memory Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_run_memory`: "}]}
2262
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_run_model Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_run_model`: "}]}
2239
2263
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_run_sessions Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_run_sessions`: "}]}
2240
2264
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.pwn_ai_run_skills Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.pwn_ai_run_skills`: "}]}
2241
2265
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL.ready_tty! Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL.ready_tty!`: "}]}
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.696
4
+ version: 0.5.698
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.