pwn 0.5.613 → 0.5.615

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +10 -10
  3. data/README.md +110 -24
  4. data/documentation/AI-Integration.md +36 -0
  5. data/documentation/BurpSuite.md +26 -0
  6. data/documentation/Contributing.md +31 -0
  7. data/documentation/Diagrams.md +57 -0
  8. data/documentation/Drivers.md +25 -0
  9. data/documentation/General-PWN-Usage.md +40 -0
  10. data/documentation/Home.md +51 -0
  11. data/documentation/How-PWN-Works.md +60 -0
  12. data/documentation/Installation.md +50 -0
  13. data/documentation/NmapIt.md +18 -0
  14. data/documentation/Plugins.md +59 -0
  15. data/documentation/Reporting.md +28 -0
  16. data/documentation/SAST.md +32 -0
  17. data/documentation/Skills-Memory-Learning.md +44 -0
  18. data/documentation/Transparent-Browser.md +32 -0
  19. data/documentation/Troubleshooting.md +38 -0
  20. data/documentation/What-is-PWN.md +34 -0
  21. data/documentation/Why-PWN.md +18 -0
  22. data/documentation/diagrams/ai-integration-tool-calling.svg +134 -0
  23. data/documentation/diagrams/burp-vs-zap-preference.svg +84 -0
  24. data/documentation/diagrams/code-scanning-sast.svg +112 -0
  25. data/documentation/diagrams/dot/ai-integration-tool-calling.dot +23 -0
  26. data/documentation/diagrams/dot/burp-vs-zap-preference.dot +15 -0
  27. data/documentation/diagrams/dot/code-scanning-sast.dot +21 -0
  28. data/documentation/diagrams/dot/driver-framework.dot +17 -0
  29. data/documentation/diagrams/dot/fuzzing-workflow.dot +27 -0
  30. data/documentation/diagrams/dot/history-to-drivers.dot +20 -0
  31. data/documentation/diagrams/dot/memory-skills-detailed.dot +28 -0
  32. data/documentation/diagrams/dot/network-infra-testing.dot +22 -0
  33. data/documentation/diagrams/dot/overall-pwn-architecture.dot +49 -0
  34. data/documentation/diagrams/dot/penetration-testing-workflow.dot +29 -0
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +31 -0
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +57 -0
  37. data/documentation/diagrams/dot/pwn-repl-prototyping.dot +37 -0
  38. data/documentation/diagrams/dot/reporting-pipeline.dot +17 -0
  39. data/documentation/diagrams/dot/reverse-engineering-flow.dot +21 -0
  40. data/documentation/diagrams/dot/sessions-cron-automation.dot +17 -0
  41. data/documentation/diagrams/dot/web-application-testing.dot +27 -0
  42. data/documentation/diagrams/dot/zero-day-research-flow.dot +27 -0
  43. data/documentation/diagrams/driver-framework.svg +90 -0
  44. data/documentation/diagrams/fuzzing-workflow.svg +147 -0
  45. data/documentation/diagrams/history-to-drivers.svg +117 -0
  46. data/documentation/diagrams/memory-skills-detailed.svg +154 -0
  47. data/documentation/diagrams/network-infra-testing.svg +121 -0
  48. data/documentation/diagrams/overall-pwn-architecture.svg +234 -0
  49. data/documentation/diagrams/penetration-testing-workflow.svg +148 -0
  50. data/documentation/diagrams/plugin-ecosystem.svg +172 -0
  51. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +234 -0
  52. data/documentation/diagrams/pwn-repl-prototyping.svg +179 -0
  53. data/documentation/diagrams/reporting-pipeline.svg +91 -0
  54. data/documentation/diagrams/reverse-engineering-flow.svg +114 -0
  55. data/documentation/diagrams/sessions-cron-automation.svg +92 -0
  56. data/documentation/diagrams/web-application-testing.svg +137 -0
  57. data/documentation/diagrams/zero-day-research-flow.svg +149 -0
  58. data/documentation/pwn-REPL.md +33 -0
  59. data/documentation/pwn-ai-Agent.md +43 -0
  60. data/lib/pwn/ai/agent/learning.rb +377 -0
  61. data/lib/pwn/ai/agent/loop.rb +18 -3
  62. data/lib/pwn/ai/agent/metrics.rb +155 -0
  63. data/lib/pwn/ai/agent/prompt_builder.rb +23 -3
  64. data/lib/pwn/ai/agent/tools/learning.rb +113 -0
  65. data/lib/pwn/ai/agent/tools/skills.rb +94 -8
  66. data/lib/pwn/ai/agent.rb +2 -0
  67. data/lib/pwn/ai/grok.rb +249 -106
  68. data/lib/pwn/config.rb +86 -13
  69. data/lib/pwn/plugins/monkey_patch.rb +2 -2
  70. data/lib/pwn/plugins/repl.rb +68 -3
  71. data/lib/pwn/version.rb +1 -1
  72. data/spec/lib/pwn/ai/agent/learning_spec.rb +68 -0
  73. data/spec/lib/pwn/ai/agent/metrics_spec.rb +27 -0
  74. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +30 -0
  75. metadata +84 -34
  76. data/documentation/PWN.png +0 -0
  77. data/documentation/PWN_Contributors_and_Users.png +0 -0
  78. data/documentation/fax-spectrogram.png +0 -0
  79. data/documentation/fax-waveform.png +0 -0
  80. data/documentation/frozen_string_literal_files_changed_from_false_to_true.txt +0 -54
  81. data/documentation/lifecycle_authz_replay.example.yaml +0 -27
  82. data/documentation/pwn_android_war_dialer_session.png +0 -0
  83. data/documentation/pwn_install.png +0 -0
  84. data/documentation/pwn_wallpaper.jpg +0 -0
  85. data/documentation/ringing-spectrogram.png +0 -0
  86. data/documentation/ringing-waveform.png +0 -0
  87. data/documentation/vulnerability_report_template.md +0 -37
data/lib/pwn/ai/grok.rb CHANGED
@@ -13,115 +13,227 @@ module PWN
13
13
  # API documentation: https://docs.x.ai/docs
14
14
  # Obtain an API key from https://x.ai/api
15
15
  module Grok
16
+ # Internal helper: true when +opts[:value]+ is a *real* configured value coming
17
+ # from PWN::Config / pwn-vault (i.e. not nil, not blank, and not one of
18
+ # the "optional - ..." / "required - ..." placeholder strings that
19
+ # PWN::Config.default_env writes into a fresh ~/.pwn/pwn.yaml).
20
+ # Used so OAuth + API key resolution behaves correctly regardless of
21
+ # whether the user edited every field.
22
+ private_class_method def self.real_config_value?(opts = {})
23
+ s = opts[:value].to_s.strip
24
+ return false if s.empty?
25
+ return false if s.match?(/\A(optional|required)\b/i)
26
+
27
+ true
28
+ end
29
+
30
+ # ------------------------------------------------------------------
31
+ # xAI Grok OAuth (SuperGrok) -- public-client OIDC, no client_secret.
32
+ #
33
+ # auth.x.ai is a full OIDC provider (/.well-known/openid-configuration).
34
+ # xAI ships a PUBLIC client for the Grok CLI; the same client_id is used
35
+ # by NousResearch/hermes-agent (`hermes auth add xai-oauth`) and is reused
36
+ # here so PWN can obtain a Bearer for https://api.x.ai/v1 without an API
37
+ # key. token_endpoint_auth_methods_supported includes 'none', so no
38
+ # client_secret is required -- only PKCE / device_code.
39
+ #
40
+ # Two grants are implemented:
41
+ # * RFC 8628 device_authorization -> default; headless / SSH friendly.
42
+ # * refresh_token -> silent renewal once enrolled.
43
+ #
44
+ # Access tokens are short-lived JWTs (ES256, `exp` claim). The
45
+ # refresh_token is long-lived -- persist it via pwn-vault under
46
+ # ai.grok.oauth.refresh_token and PWN::AI::Grok refreshes transparently
47
+ # on every run.
48
+ # ------------------------------------------------------------------
49
+ XAI_OAUTH_ISSUER = 'https://auth.x.ai'
50
+ XAI_OAUTH_DEVICE_URI = "#{XAI_OAUTH_ISSUER}/oauth2/device/code".freeze
51
+ XAI_OAUTH_TOKEN_URI = "#{XAI_OAUTH_ISSUER}/oauth2/token".freeze
52
+ # Public Grok-CLI client_id (same one hermes-agent uses).
53
+ XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
54
+ XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
55
+
56
+ # Internal: decode a JWT payload (no sig verification) to read `exp`.
57
+ private_class_method def self.jwt_exp(opts = {})
58
+ seg = opts[:token].to_s.split('.')[1]
59
+ return nil unless seg
60
+
61
+ seg += '=' * ((4 - (seg.length % 4)) % 4)
62
+ JSON.parse(Base64.urlsafe_decode64(seg))['exp']
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ # Internal: true when +opts[:token]+ is absent, not a JWT, or expires within
68
+ # +opts[:skew]+ seconds (default 120).
69
+ private_class_method def self.oauth_token_expiring?(opts = {})
70
+ token = opts[:token]
71
+ skew = opts[:skew] || 120
72
+ return true unless real_config_value?(value: token)
73
+
74
+ exp = jwt_exp(token: token)
75
+ return false if exp.nil? # opaque token -- trust it
76
+
77
+ Time.now.to_i >= (exp.to_i - skew)
78
+ end
79
+
16
80
  # Supported Method Parameters::
17
- # bearer = PWN::AI::Grok.obtain_oauth_bearer_token(
18
- # client_id: 'xAI OAuth Client ID',
19
- # client_secret: 'xAI OAuth Client Secret'
20
- # token_uri: 'optional - xAI OAuth token endpoint (defaults to https://auth.x.ai/oauth2/token)'
81
+ # access_token = PWN::AI::Grok.refresh_oauth_bearer_token(
82
+ # refresh_token: 'required - xAI OAuth refresh_token',
83
+ # client_id: 'optional - defaults to public Grok-CLI client',
84
+ # token_uri: 'optional - defaults to https://auth.x.ai/oauth2/token'
21
85
  # )
22
86
  #
23
- # Internal: only invoked when oauth config (client_id etc) is present and no valid bearer_token.
24
- # Constructs the authorize URL (https://auth.x.ai/oauth2/authorize) for the user to complete
25
- # consent in browser (standard for authorization_code flow). Prompts for the code returned
26
- # after redirect (OOB), then exchanges at token_uri for the bearer_token.
27
- # This fulfills calling the authorize endpoint (via URL) only when oauth configured.
28
- # Uses xAI's supported scopes like grok-cli:access. Stores result in the oauth hash for the session.
87
+ # Exchanges a refresh_token for a fresh access_token at auth.x.ai.
88
+ # On success, writes :bearer_token (and a rotated :refresh_token if
89
+ # returned) back into the passed opts/oauth Hash so the live PWN::Env
90
+ # stays warm for the rest of the process.
91
+ public_class_method def self.refresh_oauth_bearer_token(opts = {})
92
+ refresh_token = opts[:refresh_token]
93
+ raise 'refresh_token is required' unless real_config_value?(value: refresh_token)
94
+
95
+ client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : XAI_OAUTH_CLIENT_ID
96
+ token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : XAI_OAUTH_TOKEN_URI
97
+
98
+ resp = RestClient.post(
99
+ token_uri,
100
+ {
101
+ grant_type: 'refresh_token',
102
+ refresh_token: refresh_token,
103
+ client_id: client_id
104
+ },
105
+ content_type: 'application/x-www-form-urlencoded',
106
+ accept: 'application/json'
107
+ )
108
+ data = JSON.parse(resp.body)
109
+ raise "xAI OAuth refresh error: #{data['error']} - #{data['error_description']}" if data['error']
110
+
111
+ opts[:bearer_token] = data['access_token']
112
+ opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
113
+ opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
114
+ data['access_token']
115
+ rescue RestClient::ExceptionWithResponse => e
116
+ raise "xAI OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
117
+ end
118
+
29
119
  # Supported Method Parameters::
30
120
  # 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)'
121
+ # client_id: 'optional - xAI OAuth Client ID (defaults to public Grok-CLI client)',
122
+ # scope: 'optional - space-delimited scopes (defaults to XAI_OAUTH_SCOPE)',
123
+ # timeout: 'optional - seconds to wait for user consent (default 300)'
34
124
  # )
35
125
  #
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).
126
+ # Runs the RFC 8628 OAuth 2.0 Device Authorization Grant against
127
+ # auth.x.ai using xAI's public Grok-CLI client (no client_secret).
128
+ # This is the same identity path `hermes auth add xai-oauth` uses --
129
+ # a SuperGrok / X Premium+ subscription on the account is what grants
130
+ # api:access at consent time.
39
131
  #
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.)
132
+ # 1. POST /oauth2/device/code -> device_code, user_code, verification_uri
133
+ # 2. User opens verification_uri_complete in a browser and approves.
134
+ # 3. Poll POST /oauth2/token (grant_type=device_code) until access_token.
135
+ #
136
+ # On success the access_token + refresh_token are written back into the
137
+ # passed opts/oauth Hash (so PWN::Env[:ai][:grok][:oauth] is live-cached)
138
+ # and the operator is told exactly what to persist via pwn-vault.
45
139
  public_class_method def self.obtain_oauth_bearer_token(opts = {})
46
- client_id = opts[:client_id]
47
- client_secret = opts[:client_secret]
48
-
49
- scope = 'grok-cli:access'
50
- redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
51
- auth_uri = 'https://auth.x.ai/oauth2/authorize'
52
- token_uri = opts[:token_uri] || 'https://auth.x.ai/oauth2/token'
53
-
54
- # Build authorize URL -- this is the "call" to the authorize endpoint (user opens to consent)
55
- params = {
56
- client_id: client_id,
57
- response_type: 'code',
58
- scope: scope,
59
- redirect_uri: redirect_uri
60
- }
61
- authorize_url = "#{auth_uri}?#{URI.encode_www_form(params)}"
140
+ client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : XAI_OAUTH_CLIENT_ID
141
+ scope = real_config_value?(value: opts[:scope]) ? opts[:scope] : XAI_OAUTH_SCOPE
142
+ token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : XAI_OAUTH_TOKEN_URI
143
+ timeout = (opts[:timeout] || 300).to_i
144
+
145
+ # -- Step 1: request device + user code -------------------------------
146
+ dev = JSON.parse(
147
+ RestClient.post(
148
+ XAI_OAUTH_DEVICE_URI,
149
+ { client_id: client_id, scope: scope },
150
+ content_type: 'application/x-www-form-urlencoded',
151
+ accept: 'application/json'
152
+ ).body
153
+ )
154
+ raise "xAI device_code error: #{dev['error']} - #{dev['error_description']}" if dev['error']
62
155
 
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}"
156
+ device_code = dev['device_code']
157
+ user_code = dev['user_code']
158
+ verify_uri = dev['verification_uri_complete'] || dev['verification_uri']
159
+ interval = (dev['interval'] || 5).to_i
160
+ expires_in = (dev['expires_in'] || timeout).to_i
161
+ deadline = Time.now.to_i + [expires_in, timeout].min
162
+
163
+ puts "\n[*] xAI Grok OAuth -- Device Authorization (RFC 8628, public client, no secret)"
164
+ puts ' A SuperGrok / X Premium+ subscription on the approving account is required.'
69
165
  puts ''
70
- puts ' Step 2: After consent you will see (or be redirected to) an authorization code. Copy it exactly.'
166
+ puts ' Step 1: In a browser (any device), open:'
167
+ puts " #{verify_uri}"
168
+ puts " Step 2: Confirm the code matches: #{user_code}"
169
+ puts ' Step 3: Approve access for "Grok CLI".'
71
170
  puts ''
171
+ puts " Waiting for approval (polling every #{interval}s, timeout #{deadline - Time.now.to_i}s)..."
172
+
173
+ # -- Step 2: poll the token endpoint ---------------------------------
174
+ data = nil
175
+ loop do
176
+ sleep interval
177
+ begin
178
+ tok = RestClient.post(
179
+ token_uri,
180
+ {
181
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
182
+ device_code: device_code,
183
+ client_id: client_id
184
+ },
185
+ content_type: 'application/x-www-form-urlencoded',
186
+ accept: 'application/json'
187
+ )
188
+ data = JSON.parse(tok.body)
189
+ rescue RestClient::ExceptionWithResponse => e
190
+ data = begin
191
+ JSON.parse(e.response.body)
192
+ rescue StandardError
193
+ { 'error' => "http_#{e.http_code}", 'error_description' => e.response&.body }
194
+ end
195
+ end
72
196
 
73
- code = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Enter the authorization code from xAI OAuth')
74
-
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}")}"
79
- payload = {
80
- grant_type: 'authorization_code',
81
- code: code,
82
- redirect_uri: redirect_uri,
83
- client_id: client_id
84
- }
85
-
86
- response = RestClient.post(
87
- token_uri,
88
- payload,
89
- {
90
- content_type: 'application/x-www-form-urlencoded',
91
- authorization: basic
92
- }
93
- )
197
+ case data['error']
198
+ when nil
199
+ break # success
200
+ when 'authorization_pending'
201
+ raise 'xAI OAuth device flow timed out waiting for user approval.' if Time.now.to_i >= deadline
202
+
203
+ next
204
+ when 'slow_down'
205
+ interval += 5
206
+ next
207
+ when 'access_denied'
208
+ raise 'xAI OAuth device flow: user denied the authorization request.'
209
+ when 'expired_token'
210
+ raise 'xAI OAuth device flow: device_code expired before approval; re-run enrollment.'
211
+ else
212
+ raise "xAI OAuth device flow error: #{data['error']} - #{data['error_description']}"
213
+ end
214
+ end
94
215
 
95
- data = JSON.parse(response.body)
216
+ access_token = data['access_token']
217
+ refresh_token = data['refresh_token']
218
+ raise 'xAI OAuth token endpoint returned no access_token.' unless access_token
96
219
 
97
- if data['error']
98
- desc = data['error_description'] || data['error']
99
- raise "xAI OAuth token endpoint error: #{data['error']} - #{desc}"
100
- end
220
+ opts[:bearer_token] = access_token
221
+ opts[:refresh_token] = refresh_token if refresh_token
222
+ opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
101
223
 
102
- access_token = data['access_token']
103
-
104
- if access_token
105
- opts[:bearer_token] = access_token
106
- opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
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).'
121
- return access_token
122
- end
224
+ puts "\n[*] SUCCESS: xAI Grok OAuth bearer obtained via device_code grant."
225
+ puts ' Cached in-memory for this pwn / pwn-ai process.'
226
+ puts ''
227
+ puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
228
+ puts " ai.grok.oauth.refresh_token = #{refresh_token}" if refresh_token
229
+ puts " ai.grok.oauth.bearer_token = #{access_token}"
230
+ puts ' On future runs the refresh_token alone is enough -- PWN::AI::Grok will'
231
+ puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
232
+ puts ''
123
233
 
124
- raise 'No access_token received from xAI OAuth token endpoint (unexpected response)'
234
+ access_token
235
+ rescue RestClient::ExceptionWithResponse => e
236
+ raise "Failed to obtain Grok OAuth bearer token (HTTP #{e.http_code}): #{e.response&.body}"
125
237
  rescue StandardError => e
126
238
  raise "Failed to obtain Grok OAuth bearer token: #{e.message}"
127
239
  end
@@ -137,28 +249,59 @@ module PWN
137
249
  # timeout: 'optional timeout in seconds (defaults to 900)',
138
250
  # spinner: 'optional - display spinner (defaults to false)'
139
251
  # )
140
-
141
252
  private_class_method def self.grok_rest_call(opts = {})
142
- engine = PWN::Env[:ai][:grok]
253
+ engine = PWN::Env.dig(:ai, :grok) if defined?(PWN::Env)
143
254
  raise 'ERROR: Grok Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
144
255
 
145
- oauth = engine[:oauth] ||= {}
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).
256
+ # ------------------------------------------------------------------
257
+ # Bearer resolution (all sourced from PWN::Config / pwn-vault via
258
+ # PWN::Env[:ai][:grok]). Priority order:
259
+ # 1. oauth[:bearer_token] -> if present AND not expiring (JWT exp).
260
+ # 2. oauth[:refresh_token] -> silent refresh_token grant at auth.x.ai
261
+ # (writes new bearer back into oauth hash).
262
+ # 3. oauth device flow -> RFC 8628 enrollment via public Grok-CLI
263
+ # client. Triggered when the oauth section
264
+ # is opted-in (client_id set OR :enroll
265
+ # truthy) OR when no engine[:key] exists.
266
+ # 4. engine[:key] -> classic xAI API key.
267
+ # 5. interactive prompt -> last resort.
268
+ # Placeholder strings ("optional - ..." / "required - ...") written by
269
+ # PWN::Config.default_env are treated as UNSET.
270
+ # ------------------------------------------------------------------
271
+ oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : (engine[:oauth] ||= {})
272
+ token = nil
273
+
274
+ token = oauth[:bearer_token] if real_config_value?(value: oauth[:bearer_token]) && !oauth_token_expiring?(token: oauth[:bearer_token])
275
+
276
+ if token.nil? && real_config_value?(value: oauth[:refresh_token])
277
+ begin
278
+ token = refresh_oauth_bearer_token(oauth)
279
+ rescue StandardError => e
280
+ warn "[!] Grok OAuth refresh failed, falling back: #{e.message}"
281
+ end
282
+ end
283
+
284
+ oauth_opt_in = real_config_value?(value: oauth[:client_id]) ||
285
+ oauth[:enroll] == true ||
286
+ real_config_value?(value: oauth[:bearer_token]) ||
287
+ real_config_value?(value: oauth[:refresh_token])
288
+
289
+ if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
290
+ # Singular device-flow enrollment. Result is written back into the
291
+ # live oauth hash so subsequent grok_rest_call invocations inside the
292
+ # same pwn / pwn-ai process reuse it silently.
153
293
  token = obtain_oauth_bearer_token(oauth)
154
294
  end
155
295
 
156
- token ||= engine[:key]
157
- token ||= PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Grok API Key (oauth:client_id will auto-trigger authorize flow for bearer_token if configured)')
296
+ token = engine[:key] if token.nil? && real_config_value?(value: engine[:key])
297
+
298
+ token ||= PWN::Plugins::AuthenticationHelper.mask_password(
299
+ prompt: 'Grok API Key (or run PWN::AI::Grok.obtain_oauth_bearer_token for SuperGrok OAuth)'
300
+ )
158
301
 
159
- http_method = opts[:http_method].to_s.scrub.to_sym ||= :get
302
+ http_method = opts[:http_method].nil? ? :get : opts[:http_method].to_s.scrub.to_sym
160
303
 
161
- base_uri = engine[:base_uri] ||= 'https://api.x.ai/v1'
304
+ base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.x.ai/v1'
162
305
  rest_call = opts[:rest_call].to_s.scrub
163
306
  params = opts[:params]
164
307
  headers = {
data/lib/pwn/config.rb CHANGED
@@ -40,11 +40,16 @@ module PWN
40
40
  # OAuth support for xAI SuperGrok subscriptions (in addition to API key)
41
41
  # Populate via pwn-vault command (values stored encrypted in ~/.pwn/pwn.yaml)
42
42
  oauth: {
43
- client_id: 'optional - xAI SuperGrok OAuth Client ID (for subscriptions without API key)',
44
- client_secret: 'optional - xAI SuperGrok OAuth Client Secret',
45
- bearer_token: 'optional - xAI SuperGrok OAuth Access Token (preferred for SuperGrok subs; used as Bearer)',
46
- refresh_token: 'optional - xAI SuperGrok OAuth Refresh Token',
47
- token_uri: 'optional - OAuth token endpoint (defaults handled in PWN::AI::Grok if needed)'
43
+ # xAI Grok OAuth uses a PUBLIC client (Grok-CLI, same as hermes-agent) --
44
+ # NO client_secret. Run PWN::AI::Grok.obtain_oauth_bearer_token once
45
+ # (RFC 8628 device flow) then store refresh_token here; PWN refreshes
46
+ # the short-lived access_token automatically on every run.
47
+ refresh_token: 'optional - xAI SuperGrok OAuth Refresh Token (durable; enables silent re-auth)',
48
+ bearer_token: 'optional - xAI SuperGrok OAuth Access Token (short-lived JWT; auto-refreshed if refresh_token set)',
49
+ client_id: 'optional - override public Grok-CLI client_id (default: b1a00492-073a-47ea-816f-4c329264a828)',
50
+ scope: 'optional - override OAuth scope (default: openid profile email offline_access grok-cli:access api:access)',
51
+ token_uri: 'optional - override OAuth token endpoint (default: https://auth.x.ai/oauth2/token)',
52
+ enroll: 'optional - set true to force device-flow enrollment even when an API key is present'
48
53
  }
49
54
  },
50
55
  openai: {
@@ -226,6 +231,7 @@ module PWN
226
231
  pass
227
232
  password
228
233
  psk
234
+ refresh_token
229
235
  secret_key
230
236
  token
231
237
  ]
@@ -299,15 +305,35 @@ module PWN
299
305
  engine = env[:ai][:active].to_s.downcase.to_sym
300
306
  raise "ERROR: Unsupported AI Engine: #{engine} in #{pwn_env_path}. Supported AI Engines:\n#{valid_ai_engines.inspect}" unless valid_ai_engines.include?(engine)
301
307
 
308
+ # Determine whether the active engine already has usable auth
309
+ # material so the pwn / pwn-ai REPL driver does not prompt for an
310
+ # API key when OAuth is configured via pwn-vault.
311
+ #
312
+ # A value is considered "real" when it is non-blank AND is not one
313
+ # of the placeholder strings ("optional - ..." / "required - ...")
314
+ # written by PWN::Config.default_env into a fresh ~/.pwn/pwn.yaml.
315
+ real_cfg = lambda do |v|
316
+ s = v.to_s.strip
317
+ !s.empty? && !s.match?(/\A(optional|required)\b/i)
318
+ end
319
+
302
320
  key = env[:ai][engine][:key]
303
- oauth_access = nil
321
+ key = nil unless real_cfg.call(key)
322
+
323
+ oauth_configured = false
304
324
  if engine == :grok
305
- oauth = env[:ai][engine][:oauth] ||= {}
306
- oauth_access = oauth[:bearer_token] if oauth[:bearer_token] && !oauth[:bearer_token].to_s.match?(/optional/i) && !oauth[:bearer_token].to_s.empty?
325
+ oauth = env[:ai][engine][:oauth]
326
+ oauth = env[:ai][engine][:oauth] = {} unless oauth.is_a?(Hash)
327
+ # OAuth is considered configured when either a bearer_token is
328
+ # stored (preferred, long-lived) OR client_id + client_secret are
329
+ # present (PWN::AI::Grok will run the singular enrollment flow).
330
+ oauth_configured = real_cfg.call(oauth[:bearer_token]) ||
331
+ (real_cfg.call(oauth[:client_id]) && real_cfg.call(oauth[:client_secret]))
307
332
  end
308
- if key.nil? && oauth_access.nil?
333
+
334
+ if key.nil? && !oauth_configured
309
335
  key = PWN::Plugins::AuthenticationHelper.mask_password(
310
- prompt: "#{engine} API Key (or configure oauth:bearer_token in pwn-vault for xAI SuperGrok subscriptions)"
336
+ prompt: "#{engine} API Key (or store ai.grok.oauth.refresh_token via pwn-vault -- run PWN::AI::Grok.obtain_oauth_bearer_token to enroll)"
311
337
  )
312
338
  env[:ai][engine][:key] = key
313
339
  end
@@ -372,6 +398,53 @@ module PWN
372
398
  File.join(File.dirname(pwn_env_path), 'skills')
373
399
  end
374
400
 
401
+ # Supported Method Parameters::
402
+ # refs = PWN::Config.parse_skill_references(content: '...')
403
+ #
404
+ # Extracts an Array of reference strings (URLs, CWE/CVE/ATT&CK ids, etc.)
405
+ # from a skill body. Supports two formats:
406
+ # 1) YAML front-matter block: ---\nreferences:\n - https://...\n---\n
407
+ # 2) Markdown section: ## References\n- https://...\n
408
+ public_class_method def self.parse_skill_references(opts = {})
409
+ content = opts[:content].to_s
410
+ refs = []
411
+
412
+ # YAML front-matter (--- ... ---) at top of file
413
+ if content.start_with?("---\n")
414
+ fm_end = content.index("\n---", 4)
415
+ if fm_end
416
+ begin
417
+ require 'yaml'
418
+ fm = YAML.safe_load(content[4..fm_end], permitted_classes: [], aliases: false) || {}
419
+ r = fm['references'] || fm[:references]
420
+ refs.concat(Array(r).map(&:to_s)) if r
421
+ rescue StandardError
422
+ # ignore malformed front-matter
423
+ end
424
+ end
425
+ end
426
+
427
+ # Markdown "## References" section (bullets or bare lines until next heading / EOF)
428
+ if content =~ /^\s*\#{1,3}\s*References\s*$/i
429
+ in_section = false
430
+ content.each_line do |line|
431
+ if line =~ /^\s*\#{1,3}\s*References\s*$/i
432
+ in_section = true
433
+ next
434
+ end
435
+ next unless in_section
436
+ break if line =~ /^\s*\#{1,3}\s+\S/ # next heading
437
+
438
+ l = line.strip.sub(/^[-*]\s*/, '')
439
+ refs << l unless l.empty?
440
+ end
441
+ end
442
+
443
+ refs.map(&:strip).reject(&:empty?).uniq
444
+ rescue StandardError
445
+ []
446
+ end
447
+
375
448
  # Supported Method Parameters::
376
449
  # skills = PWN::Config.load_skills(
377
450
  # pwn_skills_path: 'optional - Path to skills folder. Defaults to ~/.pwn/skills'
@@ -396,12 +469,12 @@ module PWN
396
469
  if ext == '.rb'
397
470
  begin
398
471
  require skill_file
399
- skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true }
472
+ skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true, references: parse_skill_references(content: content) }
400
473
  rescue StandardError => e
401
- skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message }
474
+ skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message, references: parse_skill_references(content: content) }
402
475
  end
403
476
  else
404
- skills[basename] = { type: :instruction, path: skill_file, content: content }
477
+ skills[basename] = { type: :instruction, path: skill_file, content: content, references: parse_skill_references(content: content) }
405
478
  end
406
479
  end
407
480
 
@@ -61,8 +61,8 @@ module PWN
61
61
  begin
62
62
  # unless process_command_safely(line)
63
63
  unless process_command_safely(line) && (
64
- line.empty? || @eval_string.empty?
65
- )
64
+ line.empty? || @eval_string.empty?
65
+ )
66
66
  # @eval_string += "#{line.chomp}\n" if !line.empty? || !@eval_string.empty?
67
67
  @eval_string += "#{line.chomp}\n"
68
68
  end