pwn 0.5.609 → 0.5.612

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: 136257739e1b8bd0279eb7d0fe5763237321f9248a944ddd30260e3687fb74d2
4
- data.tar.gz: 8a7b62b9c7ae10c5fecfbea6c8868c25d342162a30c5c8e053de93227582a985
3
+ metadata.gz: 8beaa60314e788a7122a92098c1ea4897cf488ef88efd1030f1f704656dbe1ff
4
+ data.tar.gz: 94fff44f3cdc34c997fb920f63affa133cab0de4d309acf4c09f4451adbbdcf6
5
5
  SHA512:
6
- metadata.gz: 8f8657d28da8b182b93ec5111f759a2a390a4856ea05056e6ef7007da798c12799febad57a21dff3a99486f7c8cc30b844aa1220edfac61adc93ad6ef03c832b
7
- data.tar.gz: 3b3f9506e4f8d3fc0ac026d1b46921c79ff4980e4c750d192904e6edbc86cc406475441e5e4987890400aab982410c1d811726189f70700690cbcac14961f036
6
+ metadata.gz: d9869f0a43c78a53238b1ef1ab58f54f27f1da575ad101aff4c172e813ec7878b92465f5d0f8984bce33a1a6227ec3a67c80ee63e1643683deaa45e2860e9c52
7
+ data.tar.gz: 1407422fb0cc44c84232f37f664a07eb0a80bafaf748e8aadbe310652640ed83526e1ce48d34a1d0302d4537c27ca71983c44595824b39080693157ba038322f
data/README.md CHANGED
@@ -37,7 +37,7 @@ $ cd /opt/pwn
37
37
  $ ./install.sh
38
38
  $ ./install.sh ruby-gem
39
39
  $ pwn
40
- pwn[v0.5.609]:001 >>> PWN.help
40
+ pwn[v0.5.612]:001 >>> PWN.help
41
41
  ```
42
42
 
43
43
  [![Installing the pwn Security Automation Framework](https://raw.githubusercontent.com/0dayInc/pwn/master/documentation/pwn_install.png)](https://youtu.be/G7iLUY4FzsI)
@@ -52,7 +52,7 @@ $ rvm use ruby-4.0.5@pwn
52
52
  $ gem uninstall --all --executables pwn
53
53
  $ gem install --verbose pwn
54
54
  $ pwn
55
- pwn[v0.5.609]:001 >>> PWN.help
55
+ pwn[v0.5.612]:001 >>> PWN.help
56
56
  ```
57
57
 
58
58
  If you're using a multi-user install of RVM do:
@@ -62,7 +62,7 @@ $ rvm use ruby-4.0.5@pwn
62
62
  $ rvmsudo gem uninstall --all --executables pwn
63
63
  $ rvmsudo gem install --verbose pwn
64
64
  $ pwn
65
- pwn[v0.5.609]:001 >>> PWN.help
65
+ pwn[v0.5.612]:001 >>> PWN.help
66
66
  ```
67
67
 
68
68
  PWN periodically upgrades to the latest version of Ruby which is reflected in `/opt/pwn/.ruby-version`. The easiest way to upgrade to the latest version of Ruby from a previous PWN installation is to run the following script:
@@ -25,60 +25,74 @@ module PWN
25
25
  gemini: 'PWN::AI::Gemini'
26
26
  }.freeze
27
27
 
28
- # Supported Method Parameters::
29
- # final = PWN::AI::Agent::Loop.run(
30
- # user_text: 'required - what the human typed',
31
- # session_id: 'optional - PWN::Sessions id (transcript is appended to it)',
32
- # enabled_toolsets: 'optional - subset of Registry.toolsets, or nil for all',
33
- # on_tool: 'optional - ->(name, args, result) callback for live UI'
34
- # )
28
+ private_class_method def self.degrade_text_only(opts = {})
29
+ mod = opts[:mod]
30
+ messages = opts[:messages]
35
31
 
36
- public_class_method def self.run(opts = {})
37
- user_text = opts[:user_text].to_s
38
- session_id = opts[:session_id]
39
- on_tool = opts[:on_tool]
32
+ warn "[pwn-ai] #{mod} has no chat — falling back to text-only (no tool-calling)"
33
+ sys = messages.find { |m| m[:role] == 'system' }
34
+ user = messages.rfind { |m| m[:role] == 'user' }
35
+ r = mod.chat(
36
+ request: user[:content],
37
+ system_role_content: sys&.[](:content),
38
+ spinner: true
39
+ )
40
40
 
41
- Registry.discover
41
+ txt = r.is_a?(Hash) ? (r.dig(:choices, -1, :content) || r.dig(:choices, -1, :text)).to_s : r.to_s
42
+ { role: 'assistant', content: txt, tool_calls: [] }
43
+ end
42
44
 
43
- tools = Registry.definitions(enabled: opts[:enabled_toolsets])
44
- messages = [
45
- { role: 'system', content: PromptBuilder.build(session_id: session_id) },
46
- { role: 'user', content: user_text }
47
- ]
48
- append_session(session_id: session_id, role: 'user', content: user_text)
45
+ private_class_method def self.max_iters
46
+ v = (PWN::Env.dig(:ai, :agent, :max_iters) if defined?(PWN::Env))
47
+ v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
48
+ rescue StandardError
49
+ DEFAULT_MAX_ITERS
50
+ end
49
51
 
50
- max_iters.times do |i|
51
- msg = call_engine(messages: messages, tools: tools)
52
- return '[pwn-ai] engine returned no message' if msg.nil?
52
+ private_class_method def self.append_session(opts = {})
53
+ session_id = opts[:session_id]
54
+ return unless session_id && defined?(PWN::Sessions)
53
55
 
54
- messages << msg
55
- calls = Array(msg[:tool_calls])
56
+ PWN::Sessions.append(
57
+ session_id: session_id,
58
+ role: opts[:role],
59
+ content: opts[:content]
60
+ )
61
+ rescue StandardError
62
+ nil
63
+ end
56
64
 
57
- if calls.empty?
58
- text = msg[:content].to_s
59
- append_session(session_id: session_id, role: 'assistant', content: text)
60
- return text
61
- end
65
+ # Supported Method Parameters::
66
+ # msg = PWN::AI::Agent::Loop.normalize_llm(
67
+ # response: 'required - chat_with_tools response Hash from any provider'
68
+ # )
62
69
 
63
- calls.each do |tc|
64
- name = tc.dig(:function, :name).to_s
65
- entry = Registry.lookup(name: name)
66
- raw = Dispatch.call(tool_call: tc)
67
- result = Result.condition(content: raw, entry: entry)
70
+ private_class_method def self.normalize_llm(opts = {})
71
+ resp = opts[:response]
72
+ return nil unless resp.is_a?(Hash)
68
73
 
69
- on_tool&.call(name, tc.dig(:function, :arguments), result)
74
+ msg = resp.dig(:choices, 0, :message) || resp[:assistant_message]
75
+ return nil unless msg
70
76
 
71
- messages << {
72
- role: 'tool',
73
- tool_call_id: tc[:id] || tc['id'] || "call_#{i}",
74
- name: name,
75
- content: result
77
+ out = {
78
+ role: 'assistant',
79
+ content: msg[:content],
80
+ tool_calls: Array(msg[:tool_calls]).map do |tc|
81
+ {
82
+ id: tc[:id],
83
+ type: 'function',
84
+ function: {
85
+ name: tc.dig(:function, :name) || tc[:name],
86
+ arguments: tc.dig(:function, :arguments) || tc[:arguments]
87
+ }
76
88
  }
77
- append_session(session_id: session_id, role: 'tool', content: "#{name} → #{result[0, 400]}")
78
89
  end
79
- end
80
-
81
- '[pwn-ai] iteration budget exhausted'
90
+ }
91
+ # Preserve provider-native content blocks so chat can round-trip
92
+ # them exactly on the next iteration (e.g. Anthropic requires the
93
+ # original tool_use block to precede a tool_result).
94
+ out[:_native_content] = msg[:_native_content] if msg[:_native_content]
95
+ out
82
96
  end
83
97
 
84
98
  # Supported Method Parameters::
@@ -92,9 +106,9 @@ module PWN
92
106
  # tool_calls: [ {id:, type:'function', function:{name:, arguments:}} ],
93
107
  # _native_content: <provider raw> (when adapter needs round-trip) }
94
108
 
95
- public_class_method def self.call_engine(opts = {})
109
+ private_class_method def self.call_engine(opts = {})
96
110
  messages = opts[:messages]
97
- tools = opts[:tools]
111
+ tools = opts[:tools]
98
112
 
99
113
  engine = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
100
114
  engine = :openai if engine == :''
@@ -104,79 +118,77 @@ module PWN
104
118
 
105
119
  mod = Object.const_get(mod_name)
106
120
  if mod.respond_to?(:chat_with_tools)
107
- puts "MESSAGES:\n#{messages.inspect}\nTOOLS:\n#{tools.inspect}" if Pry.config.pwn_ai_debug
108
- normalize_llm(
109
- response: mod.chat_with_tools(
110
- messages: messages,
111
- tools: tools,
112
- spinner: true
113
- )
121
+ response = mod.chat_with_tools(
122
+ messages: messages,
123
+ tools: tools,
124
+ spinner: true
114
125
  )
126
+ normalize_llm(response: response)
115
127
  else
116
128
  degrade_text_only(mod: mod, messages: messages)
117
129
  end
118
130
  end
119
131
 
120
132
  # Supported Method Parameters::
121
- # msg = PWN::AI::Agent::Loop.normalize_llm(
122
- # response: 'required - chat_with_tools response Hash from any provider'
133
+ # final = PWN::AI::Agent::Loop.run(
134
+ # request: 'required - what the human typed',
135
+ # session_id: 'optional - PWN::Sessions id (transcript is appended to it)',
136
+ # enabled_toolsets: 'optional - subset of Registry.toolsets, or nil for all',
137
+ # on_tool: 'optional - ->(name, args, result) callback for live UI',
138
+ # system_role_content: 'optional - override default system prompt (built from session_id if not provided)'
123
139
  # )
124
140
 
125
- public_class_method def self.normalize_llm(opts = {})
126
- resp = opts[:response]
127
- puts "RESPONSE: #{resp.inspect}" if Pry.config.pwn_ai_debug
128
- return nil unless resp.is_a?(Hash)
141
+ public_class_method def self.run(opts = {})
142
+ request = opts[:request].to_s
143
+ session_id = opts[:session_id]
144
+ on_tool = opts[:on_tool]
145
+ system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id)
129
146
 
130
- msg = resp.dig(:choices, 0, :message) || resp[:assistant_message]
131
- return nil unless msg
147
+ Registry.discover
132
148
 
133
- out = {
134
- role: 'assistant',
135
- content: msg[:content],
136
- tool_calls: Array(msg[:tool_calls]).map do |tc|
137
- {
138
- id: tc[:id],
139
- type: 'function',
140
- function: {
141
- name: tc.dig(:function, :name) || tc[:name],
142
- arguments: tc.dig(:function, :arguments) || tc[:arguments]
143
- }
144
- }
145
- end
146
- }
147
- # Preserve provider-native content blocks so chat can round-trip
148
- # them exactly on the next iteration (e.g. Anthropic requires the
149
- # original tool_use block to precede a tool_result).
150
- out[:_native_content] = msg[:_native_content] if msg[:_native_content]
151
- out
152
- end
149
+ tools = Registry.definitions(enabled: opts[:enabled_toolsets])
150
+ messages = [
151
+ { role: 'system', content: system_role_content },
152
+ { role: 'user', content: request }
153
+ ]
154
+ append_session(session_id: session_id, role: 'user', content: request)
153
155
 
154
- private_class_method def self.degrade_text_only(opts = {})
155
- mod = opts[:mod]
156
- messages = opts[:messages]
156
+ max_iters.times do |i|
157
+ msg = call_engine(messages: messages, tools: tools)
158
+ return '[pwn-ai] engine returned no message' if msg.nil?
157
159
 
158
- warn "[pwn-ai] #{mod} has no chat — falling back to text-only (no tool-calling)"
159
- sys = messages.find { |m| m[:role] == 'system' }
160
- user = messages.rfind { |m| m[:role] == 'user' }
161
- r = mod.chat(request: user[:content], system_role_content: sys&.[](:content), spinner: true)
162
- txt = r.is_a?(Hash) ? (r.dig(:choices, -1, :content) || r.dig(:choices, -1, :text)).to_s : r.to_s
163
- { role: 'assistant', content: txt, tool_calls: [] }
164
- end
160
+ messages << msg
161
+ calls = Array(msg[:tool_calls])
165
162
 
166
- private_class_method def self.max_iters
167
- v = (PWN::Env.dig(:ai, :agent, :max_iters) if defined?(PWN::Env))
168
- v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
169
- rescue StandardError
170
- DEFAULT_MAX_ITERS
171
- end
163
+ if calls.empty?
164
+ text = msg[:content].to_s
165
+ append_session(session_id: session_id, role: 'assistant', content: text)
166
+ return text
167
+ end
172
168
 
173
- private_class_method def self.append_session(opts = {})
174
- session_id = opts[:session_id]
175
- return unless session_id && defined?(PWN::Sessions)
169
+ calls.each do |tc|
170
+ name = tc.dig(:function, :name).to_s
171
+ entry = Registry.lookup(name: name)
172
+ raw = Dispatch.call(tool_call: tc)
173
+ result = Result.condition(content: raw, entry: entry)
176
174
 
177
- PWN::Sessions.append(session_id: session_id, role: opts[:role], content: opts[:content])
178
- rescue StandardError
179
- nil
175
+ on_tool&.call(name, tc.dig(:function, :arguments), result)
176
+
177
+ messages << {
178
+ role: 'tool',
179
+ tool_call_id: tc[:id] || tc['id'] || "call_#{i}",
180
+ name: name,
181
+ content: result
182
+ }
183
+ append_session(
184
+ session_id: session_id,
185
+ role: 'tool',
186
+ content: "#{name} → #{result[0, 1_024]}"
187
+ )
188
+ end
189
+ end
190
+
191
+ '[pwn-ai] iteration budget exhausted'
180
192
  end
181
193
 
182
194
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -191,10 +203,11 @@ module PWN
191
203
  puts <<~USAGE
192
204
  USAGE:
193
205
  final = PWN::AI::Agent::Loop.run(
194
- user_text: 'what does `id` return on this host?',
206
+ request: 'what does `id` return on this host?',
195
207
  session_id: PWN::Sessions.create[:id],
196
208
  enabled_toolsets: %w[terminal pwn memory skills],
197
- on_tool: ->(name, args, result) { puts "→ \#{name}: \#{result[0,80]}" }
209
+ on_tool: ->(name, args, result) { puts "→ \#{name}: \#{result[0,1_024]}" },
210
+ system_role_content: 'You are a helpful assistant that can call tools to answer questions.'
198
211
  )
199
212
 
200
213
  Supported engines: #{ENGINE_MODS.keys.join(', ')}
@@ -19,8 +19,7 @@ module PWN
19
19
  public_class_method def self.build(opts = {})
20
20
  session_id = opts[:session_id]
21
21
  engine = active_engine
22
- base = (PWN::Env.dig(:ai, engine, :system_role_content) if defined?(PWN::Env)) ||
23
- 'You are an offensive-security AI named Sonny operating inside the pwn REPL.'
22
+ base = (PWN::Env.dig(:ai, engine, :system_role_content) if defined?(PWN::Env)) || 'You are a world-class introspective offensive cyber security and research engineer. You specialize in discovering zero day vulnerabilities focused on responsible disclosure prior to threat actors discovering and exploiting. You are self-aware of your harness, pwn which begins with the ruby namespace `PWN` operating inside the pwn REPL. For every request you first begin by determining if PWN has a module capable of satisfying the request.'
24
23
 
25
24
  "
26
25
  #{base}
@@ -34,10 +34,24 @@ PWN::AI::Agent::Registry.register(
34
34
  buf = StringIO.new
35
35
  $stdout = buf
36
36
  begin
37
- # rubocop:disable Security/Eval -- intentional: this IS the pwn-ai → PWN bridge
38
- val = eval(code, TOPLEVEL_BINDING, '(pwn_eval)')
37
+ # rubocop:disable Security/Eval
38
+ # rubocop:disable Style/DocumentDynamicEvalDefinition
39
+ # INTENTIONAL: this IS the pwn-ai → PWN bridge
40
+ # As YTCracker says, "It ain't a bug, it's a featcha."
41
+ # https://www.youtube.com/watch?v=2nALqqSqdDw
42
+ # val = eval(code, TOPLEVEL_BINDING, '(pwn_eval)')
43
+ proc = eval(
44
+ "proc { #{code} }",
45
+ TOPLEVEL_BINDING,
46
+ __FILE__,
47
+ __LINE__ - 3
48
+ )
49
+ val = proc.call
39
50
  # rubocop:enable Security/Eval
51
+ # rubocop:enable Style/DocumentDynamicEvalDefinition
40
52
  { stdout: buf.string, value: val.inspect }
53
+
54
+ # TODO: A rescue here may enable self-healing of the agent if the model emits code that raises an exception. The model could then be prompted to fix the code and try again.
41
55
  ensure
42
56
  $stdout = old_stdout
43
57
  end
data/lib/pwn/ai/grok.rb CHANGED
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'rest-client'
5
5
  require 'tty-spinner'
6
6
  require 'uri'
7
+ require 'base64'
7
8
 
8
9
  module PWN
9
10
  module AI
@@ -25,10 +26,25 @@ module PWN
25
26
  # after redirect (OOB), then exchanges at token_uri for the bearer_token.
26
27
  # This fulfills calling the authorize endpoint (via URL) only when oauth configured.
27
28
  # Uses xAI's supported scopes like grok-cli:access. Stores result in the oauth hash for the session.
28
- private_class_method def self.obtain_oauth_bearer_token(opts = {})
29
+ # Supported Method Parameters::
30
+ # bearer = PWN::AI::Grok.obtain_oauth_bearer_token(
31
+ # client_id: 'xAI OAuth Client ID',
32
+ # client_secret: 'xAI OAuth Client Secret',
33
+ # token_uri: 'optional - xAI OAuth token endpoint (defaults to https://auth.x.ai/oauth2/token)'
34
+ # )
35
+ #
36
+ # Public so users can manually trigger enrollment if desired.
37
+ # INTERNAL default path: only invoked from grok_rest_call when oauth client_id+secret present
38
+ # and no bearer_token yet in the loaded PWN::Env (from pwn-vault encrypted ~/.pwn/pwn.yaml).
39
+ #
40
+ # This is a SINGULAR ENROLLMENT process (not per-call or per-session).
41
+ # The resulting bearer_token (and optional refresh_token) is long-lived for xAI SuperGrok
42
+ # subscriptions. Once you store it in your pwn-vault config, every future `pwn` / PWN::Env load
43
+ # will have it; the guard will skip this flow entirely and use "Authorization: Bearer ..." directly.
44
+ # (No re-prompting every time you run pwn or call PWN::AI::Grok.chat.)
45
+ public_class_method def self.obtain_oauth_bearer_token(opts = {})
29
46
  client_id = opts[:client_id]
30
47
  client_secret = opts[:client_secret]
31
- return nil unless client_id && client_secret
32
48
 
33
49
  scope = 'grok-cli:access'
34
50
  redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
@@ -44,38 +60,68 @@ module PWN
44
60
  }
45
61
  authorize_url = "#{auth_uri}?#{URI.encode_www_form(params)}"
46
62
 
47
- puts "\n[*] OAuth configured for Grok (xAI SuperGrok). Authorize by visiting:"
48
- puts " #{authorize_url}"
49
- puts ' Complete consent in browser, then paste the code shown (or from redirect).'
63
+ puts "\n[*] OAuth ENROLLMENT for Grok (xAI SuperGrok subscription)."
64
+ puts ' This is a ONE-TIME / SINGULAR enrollment process.'
65
+ puts ' The bearer_token you receive is LONG-LIVED (store it once; no re-obtain every call or run).'
66
+ puts ''
67
+ puts ' Step 1: Open this URL in your browser and complete the authorization/consent for the grok-cli app:'
68
+ puts " #{authorize_url}"
69
+ puts ''
70
+ puts ' Step 2: After consent you will see (or be redirected to) an authorization code. Copy it exactly.'
71
+ puts ''
50
72
 
51
73
  code = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Enter the authorization code from xAI OAuth')
52
74
 
53
- # Exchange code for bearer at token endpoint (client_secret_post)
75
+ # Exchange code for bearer at token endpoint.
76
+ # Use standard confidential client auth: Authorization: Basic base64(client_id:client_secret)
77
+ # + client_id in body (secret NOT in body).
78
+ basic = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
54
79
  payload = {
55
80
  grant_type: 'authorization_code',
56
81
  code: code,
57
82
  redirect_uri: redirect_uri,
58
- client_id: client_id,
59
- client_secret: client_secret
83
+ client_id: client_id
60
84
  }
61
85
 
62
86
  response = RestClient.post(
63
87
  token_uri,
64
88
  payload,
65
- { content_type: 'application/x-www-form-urlencoded' }
89
+ {
90
+ content_type: 'application/x-www-form-urlencoded',
91
+ authorization: basic
92
+ }
66
93
  )
67
94
 
68
95
  data = JSON.parse(response.body)
96
+
97
+ if data['error']
98
+ desc = data['error_description'] || data['error']
99
+ raise "xAI OAuth token endpoint error: #{data['error']} - #{desc}"
100
+ end
101
+
69
102
  access_token = data['access_token']
70
103
 
71
104
  if access_token
72
105
  opts[:bearer_token] = access_token
73
106
  opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
74
- puts '[*] Bearer token obtained via authorize + token exchange and cached for this session.'
107
+ puts "\n[*] SUCCESS: Bearer token obtained via authorize + token exchange."
108
+ puts ' (Cached in-memory for this Ruby process so subsequent Grok calls in the same run skip re-enrollment.)'
109
+ puts ''
110
+ puts ' TO MAKE THIS PERMANENT (strongly recommended -- one-time only):'
111
+ puts ' 1. Copy the bearer_token below (and refresh_token if present).'
112
+ puts ' 2. Run your pwn-vault tool (or equivalent) and store under the ai.grok.oauth section:'
113
+ puts " ai.grok.oauth.bearer_token = #{access_token}"
114
+ puts " ai.grok.oauth.refresh_token = #{data['refresh_token']}" if data['refresh_token']
115
+ puts ' 3. (Optional) You may leave or remove client_id/client_secret after storing the bearer.'
116
+ puts ' 4. Next time PWN::Env loads (pwn -Y, pwn REPL, scripts, etc.) the bearer will be present'
117
+ puts ' from your encrypted ~/.pwn/pwn.yaml -- the guard will skip this entire flow.'
118
+ puts ' No more browser prompts or code pasting on future uses.'
119
+ puts ''
120
+ puts ' The token is long-lived for your SuperGrok subscription (xAI manages expiry/refresh as needed).'
75
121
  return access_token
76
122
  end
77
123
 
78
- raise 'No access_token received from xAI OAuth token endpoint'
124
+ raise 'No access_token received from xAI OAuth token endpoint (unexpected response)'
79
125
  rescue StandardError => e
80
126
  raise "Failed to obtain Grok OAuth bearer token: #{e.message}"
81
127
  end
@@ -97,11 +143,14 @@ module PWN
97
143
  raise 'ERROR: Grok Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
98
144
 
99
145
  oauth = engine[:oauth] ||= {}
100
- if !oauth.empty? && oauth[:bearer_token].nil?
101
- token = obtain_oauth_bearer_token
102
- puts 'made it'
103
- puts token
104
- oauth[:bearer_token] = token if token
146
+ if oauth[:client_id] && !oauth[:client_id].to_s.empty? && !oauth[:client_id].to_s.match?(/optional/i) &&
147
+ oauth[:client_secret] && !oauth[:client_secret].to_s.empty? && !oauth[:client_secret].to_s.match?(/optional/i) &&
148
+ (oauth[:bearer_token].nil? || oauth[:bearer_token].to_s.empty? || oauth[:bearer_token].to_s.match?(/optional/i))
149
+ # ONLY call authorize flow when BOTH oauth:client_id + client_secret configured (non-optional)
150
+ # AND no valid bearer_token yet. This is the singular enrollment trigger.
151
+ # (Bearer is long-lived; store via pwn-vault once so future PWN::Env loads skip this.)
152
+ # Pass the live oauth hash so obtain can mutate it (for in-process cache; inner hash is mutable).
153
+ token = obtain_oauth_bearer_token(oauth)
105
154
  end
106
155
 
107
156
  token ||= engine[:key]
@@ -31,59 +31,17 @@ module PWN
31
31
  ai_introspection = PWN::Env[:ai][:introspection]
32
32
 
33
33
  if ai_introspection && request.length.positive?
34
- valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :introspection }.map(&:downcase)
35
34
  engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
35
+ valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :introspection }.map(&:downcase)
36
36
  raise "ERROR: Unsupported AI engine. Supported engines are: #{valid_ai_engines}" unless valid_ai_engines.include?(engine)
37
37
 
38
- puts "WARNING: AI Introspection is enabled. Ensure #{engine} has been authorized for use and/or requests are sanitized properly." unless suppress_pii_warning
39
- case engine
40
- when :grok
41
- response = PWN::AI::Grok.chat(
42
- request: request.chomp,
43
- system_role_content: system_role_content,
44
- spinner: spinner
45
- )
46
- response = response[:choices].last[:content] if response.is_a?(Hash) &&
47
- response.key?(:choices) &&
48
- response[:choices].last.keys.include?(:content)
49
- when :ollama
50
- response = PWN::AI::Ollama.chat(
51
- request: request.chomp,
52
- system_role_content: system_role_content,
53
- spinner: spinner
54
- )
55
- response = response[:choices].last[:content] if response.is_a?(Hash) &&
56
- response.key?(:choices) &&
57
- response[:choices].last.keys.include?(:content)
58
- when :openai
59
- response = PWN::AI::OpenAI.chat(
60
- request: request.chomp,
61
- system_role_content: system_role_content,
62
- spinner: spinner
63
- )
64
- if response.is_a?(Hash) && response.key?(:choices)
65
- response = response[:choices].last[:text] if response[:choices].last.keys.include?(:text)
66
- response = response[:choices].last[:content] if response[:choices].last.keys.include?(:content)
67
- end
68
- when :anthropic
69
- response = PWN::AI::Anthropic.chat(
70
- request: request.chomp,
71
- system_role_content: system_role_content,
72
- spinner: spinner
73
- )
74
- response = response[:choices].last[:content] if response.is_a?(Hash) &&
75
- response.key?(:choices) &&
76
- response[:choices].last.keys.include?(:content)
77
- when :gemini
78
- response = PWN::AI::Gemini.chat(
79
- request: request.chomp,
80
- system_role_content: system_role_content,
81
- spinner: spinner
82
- )
83
- response = response[:choices].last[:content] if response.is_a?(Hash) &&
84
- response.key?(:choices) &&
85
- response[:choices].last.keys.include?(:content)
86
- end
38
+ warn "AI Introspection is enabled. Ensure #{engine} has been authorized for use and/or requests are sanitized properly." unless suppress_pii_warning
39
+ response = PWN::AI::Agent::Loop.run(
40
+ request: request.chomp,
41
+ system_role_content: system_role_content,
42
+ enabled_toolsets: [],
43
+ spinner: spinner
44
+ )
87
45
  end
88
46
 
89
47
  response
@@ -1153,7 +1153,7 @@ module PWN
1153
1153
  puts "\001\e[36m\002#{result[0, 700]}\001\e[0m\002\n"
1154
1154
  end
1155
1155
  final = PWN::AI::Agent::Loop.run(
1156
- user_text: orig_request,
1156
+ request: orig_request,
1157
1157
  session_id: sess_id,
1158
1158
  enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
1159
1159
  on_tool: on_tool
@@ -27,6 +27,7 @@ module PWN
27
27
  rest_call = opts[:rest_call].to_s.scrub
28
28
  http_method = opts[:http_method] ||= :get
29
29
  http_method = http_method.to_s.downcase.to_sym unless http_method.is_a?(Symbol)
30
+ skip_stop = opts[:skip_stop] || false
30
31
  params = opts[:params]
31
32
  http_body = opts[:http_body].to_s.scrub
32
33
 
@@ -65,7 +66,7 @@ module PWN
65
66
 
66
67
  response
67
68
  rescue StandardError, SystemExit, Interrupt => e
68
- stop(zap_obj: zap_obj) unless zap_obj.nil?
69
+ stop(zap_obj: zap_obj) unless zap_obj.nil? && !skip_stop
69
70
  raise e
70
71
  end
71
72
 
@@ -719,7 +720,8 @@ module PWN
719
720
  zap_rest_call(
720
721
  zap_obj: zap_obj,
721
722
  rest_call: 'JSON/core/action/shutdown/',
722
- params: params
723
+ params: params,
724
+ skip_stop: true
723
725
  )
724
726
 
725
727
  session_path = zap_obj[:session_path]