knoxcall 0.0.1 → 1.0.0

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. metadata +70 -9
@@ -0,0 +1,372 @@
1
+ require "uri"
2
+ require "time"
3
+ require "ipaddr"
4
+ require "knoxcall/errors"
5
+ require "knoxcall/warnings"
6
+ require "knoxcall/wrap_transport"
7
+
8
+ module KnoxCall
9
+ # Uncovered-egress observations (PARITY §21.3; founder decisions 2026-09-26).
10
+ # Ruby mirror of +sdk/knoxcall-node/src/egress-observations.ts+.
11
+ #
12
+ # A route-aware seam sees every outbound request the process makes and
13
+ # sends only the covered ones through KnoxCall. The rest go direct — and
14
+ # among them are calls that carry a credential the platform does not hold:
15
+ # "uncovered egress". This module records those (host, first path segment,
16
+ # method, credential header NAME) in memory and reports the aggregate to
17
+ # +POST /v1/wrap/egress-observations+ ({Resources::Wrap#report_egress_observations}),
18
+ # so the dashboard can show a tenant which credentials are still leaving
19
+ # their process un-custodied.
20
+ #
21
+ # What is recorded is bounded on purpose, and the bound is the feature:
22
+ # names, never values — the credential header's NAME, never its value; the
23
+ # FIRST path segment only — never the query string, never the body, never a
24
+ # deeper path; counts per (host, segment, method, header) with first/last
25
+ # seen. Only a DIRECT decision with reason +:unlisted+ is observed —
26
+ # +:own_host+, +:route_around+, +:kill_switch+, +:outside_context+ and
27
+ # +:unparseable+ never are.
28
+ #
29
+ # Nothing here may add latency to, raise into, or alter the application's
30
+ # request: {EgressObservationReporter#record} is synchronous and cheap, the
31
+ # flush runs on a background thread (Ruby threads never keep a process
32
+ # alive), and every failure is swallowed after one warning. The reporter is
33
+ # process memory only.
34
+ module EgressObservations
35
+ # Exact (case-insensitive) header names that carry a credential. The
36
+ # shared fixture +sdk/fixtures/egress-observation.json+ pins this list.
37
+ CREDENTIAL_HEADER_ALLOWLIST = %w[
38
+ authorization proxy-authorization x-api-key api-key apikey x-apikey x-auth-token x-access-token
39
+ x-token token x-secret x-secret-key x-client-secret ocp-apim-subscription-key x-goog-api-key
40
+ x-amz-security-token x-shopify-access-token klaviyo-api-key x-hubspot-api-key
41
+ ].freeze
42
+
43
+ # A lower-cased header name ending in one of these also counts.
44
+ CREDENTIAL_HEADER_SUFFIXES = %w[-api-key -token -secret -auth].freeze
45
+
46
+ RANK = CREDENTIAL_HEADER_ALLOWLIST.each_with_index.to_h.freeze
47
+
48
+ # The methods the server accepts (upper-case); anything else is +invalid_method+.
49
+ METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE].freeze
50
+
51
+ # The server's shape checks (src/wrap/egress-observations.ts).
52
+ HEADER_NAME_RE = /\A[a-z0-9][a-z0-9_-]*\z/
53
+ FIRST_SEGMENT_RE = %r{\A/[A-Za-z0-9._~!$&'()*+,;=:@%-]{0,255}\z}
54
+ MAX_HEADER_NAME_LENGTH = 64
55
+
56
+ # A credential in the first path segment (server #1022). Some APIs put one
57
+ # in the path (Telegram's /bot<id>:<secret>/...). The server stores such a
58
+ # segment as "/"; the SDK applies the SAME rule before sending.
59
+ MAX_PLAIN_FIRST_SEGMENT_LENGTH = 64
60
+ CREDENTIAL_SEGMENT_PREFIXES = [
61
+ /\Abot\d+:/i,
62
+ /\A(sk|pk|rk)_(live|test)_/i,
63
+ /\Ask-/,
64
+ /\Axox[abposr]-/,
65
+ /\Agh[pousr]_/,
66
+ /\Agithub_pat_/,
67
+ /\Aglpat-/,
68
+ /\Ashp(at|ca|pa|ss)_/,
69
+ /\A(AKIA|ASIA)[0-9A-Z]{12,}/,
70
+ /\AAIza[0-9A-Za-z_-]{20,}/,
71
+ /\AeyJ[A-Za-z0-9_-]{8,}/,
72
+ /\ASG\./
73
+ ].freeze
74
+
75
+ module_function
76
+
77
+ # Whether a header NAME (any casing) is credential-bearing.
78
+ def credential_header_name?(name)
79
+ n = name.to_s.strip.downcase
80
+ return false if n.empty? || n.length > MAX_HEADER_NAME_LENGTH || !HEADER_NAME_RE.match?(n)
81
+ return true if RANK.key?(n)
82
+
83
+ CREDENTIAL_HEADER_SUFFIXES.any? { |s| n.length > s.length && n.end_with?(s) }
84
+ end
85
+
86
+ # The credential header NAME to report for a request, or nil. Allowlist
87
+ # entries win in allowlist order; then the lexicographically smallest
88
+ # suffix match. A header whose value is empty after stripping never
89
+ # counts. Only names are read — values are looked at solely to discard
90
+ # empties and are never returned. +headers+ is a Hash (any casing; values
91
+ # String or Array) or an Enumerable of +[name, value]+ pairs.
92
+ def credential_header_name(headers)
93
+ best = nil
94
+ best_rank = CREDENTIAL_HEADER_ALLOWLIST.length + 1
95
+ best_suffix = nil
96
+ (headers || []).each do |raw_name, raw_value|
97
+ name = raw_name.to_s.strip.downcase
98
+ next if name.empty?
99
+ next if Array(raw_value).all? { |v| v.to_s.strip.empty? }
100
+
101
+ rank = RANK[name]
102
+ if rank
103
+ if rank < best_rank
104
+ best_rank = rank
105
+ best = name
106
+ end
107
+ next
108
+ end
109
+ next unless credential_header_name?(name)
110
+
111
+ best_suffix = name if best_suffix.nil? || name < best_suffix
112
+ end
113
+ best || best_suffix
114
+ end
115
+
116
+ # Whether a first segment (+/+ + one segment) looks like it carries a
117
+ # credential — the server's rule, on the raw and percent-decoded forms.
118
+ def first_segment_looks_like_credential?(first_segment)
119
+ raw = first_segment.to_s.delete_prefix("/")
120
+ return false if raw.empty?
121
+
122
+ decoded = raw.gsub(/%\h\h/) { |m| m[1..].hex.chr }.force_encoding(Encoding::UTF_8).scrub
123
+ [raw, decoded].uniq.any? do |s|
124
+ s.length > MAX_PLAIN_FIRST_SEGMENT_LENGTH ||
125
+ CREDENTIAL_SEGMENT_PREFIXES.any? { |re| re.match?(s) } ||
126
+ s.scan(/[A-Za-z0-9_-]{24,}/).any? { |run| [/[a-z]/, /[A-Z]/, /[0-9]/].count { |re| re.match?(run) } >= 2 }
127
+ end
128
+ end
129
+
130
+ # +/+ or +/<first path segment>+ of the URL — never the query, never
131
+ # deeper. Exactly one leading slash is consumed, so +//double+ reports +/+.
132
+ def first_segment(url)
133
+ path = begin
134
+ URI.parse(url.to_s).path.to_s
135
+ rescue URI::InvalidURIError
136
+ ""
137
+ end
138
+ "/#{path.delete_prefix('/').split('/', 2).first}"
139
+ end
140
+
141
+ # The whole classifier, pure: the four identifying fields for this
142
+ # request, or nil when it carries no credential-bearing header. The
143
+ # caller has ALREADY decided the request is direct + +:unlisted+.
144
+ def observation_for(url, method, headers)
145
+ host = begin
146
+ WrapTransport.normalize_host(URI.parse(url.to_s).host)
147
+ rescue URI::InvalidURIError
148
+ ""
149
+ end
150
+ return nil if host.nil? || host.empty? || ip_literal?(host) # the server drops it (ip_literal)
151
+
152
+ upper = (method.to_s.empty? ? "GET" : method.to_s).upcase
153
+ return nil unless METHODS.include?(upper)
154
+
155
+ segment = first_segment(url)
156
+ segment = "/" if first_segment_looks_like_credential?(segment) # reported as "/"; the entry is kept
157
+ return nil unless FIRST_SEGMENT_RE.match?(segment)
158
+
159
+ name = credential_header_name(headers)
160
+ return nil if name.nil?
161
+
162
+ { host: host, first_segment: segment, method: upper, header_name: name }
163
+ end
164
+
165
+ def ip_literal?(host)
166
+ IPAddr.new(host)
167
+ true
168
+ rescue IPAddr::Error, ArgumentError
169
+ false
170
+ end
171
+
172
+ # +KNOXCALL_OBSERVE_UNCOVERED=off|false|0+ turns the reporter off (read
173
+ # when a pipeline is built).
174
+ def disabled_by_env?
175
+ %w[off 0 false].include?(ENV.fetch("KNOXCALL_OBSERVE_UNCOVERED", "").strip.downcase)
176
+ end
177
+ end
178
+
179
+ # In-memory aggregation of uncovered-egress observations for ONE pipeline,
180
+ # flushed in the background. Thread-safe; bounded; never raises.
181
+ class EgressObservationReporter
182
+ DEFAULT_FLUSH_INTERVAL = 60.0
183
+ DEFAULT_FLUSH_AT_KEYS = 200
184
+ DEFAULT_MAX_KEYS = 1_000
185
+ DEFAULT_MAX_PER_REQUEST = 200
186
+
187
+ # @param report [#call] performs +POST /v1/wrap/egress-observations+ with at most +max_per_request+ observations
188
+ # @param on_flush [#call, nil] +{accepted:, dropped:}+ after each accepted report (never per observation)
189
+ def initialize(report, on_flush: nil, flush_interval: DEFAULT_FLUSH_INTERVAL, flush_at_keys: DEFAULT_FLUSH_AT_KEYS,
190
+ max_keys: DEFAULT_MAX_KEYS, max_per_request: DEFAULT_MAX_PER_REQUEST, now: nil, rand: nil)
191
+ @report = report
192
+ @on_flush = on_flush
193
+ @interval = flush_interval
194
+ @flush_at = flush_at_keys
195
+ @max_keys = max_keys
196
+ @max_per = max_per_request
197
+ @now = now || -> { Time.now.to_f }
198
+ @rand = rand || -> { Kernel.rand }
199
+ @mutex = Mutex.new
200
+ @buffer = {}
201
+ @timer = nil
202
+ @flushing = false
203
+ @stopped = false
204
+ @forbidden = false
205
+ @warned_overflow = false
206
+ @warned_failed = false
207
+ end
208
+
209
+ # Distinct keys currently held.
210
+ def size = @mutex.synchronize { @buffer.size }
211
+ def stopped? = @stopped
212
+ # True once the endpoint answered 403: reporting is off for the life of this reporter.
213
+ def forbidden? = @forbidden
214
+
215
+ # The observations that would be sent now (a copy, in first-seen order).
216
+ def pending = @mutex.synchronize { @buffer.values.map { |e| wire(e) } }
217
+
218
+ # Record one uncovered credentialed call. Synchronous, never raises.
219
+ def record(obs)
220
+ return if @stopped || @forbidden
221
+
222
+ key = [obs[:host], obs[:first_segment], obs[:method], obs[:header_name]]
223
+ at = @now.call
224
+ flush_now = false
225
+ arm = false
226
+ warn_overflow = false
227
+ @mutex.synchronize do
228
+ hit = @buffer[key]
229
+ if hit
230
+ hit[:count] += 1
231
+ hit[:last_seen] = at
232
+ return
233
+ end
234
+ if @buffer.size >= @max_keys
235
+ warn_overflow = !@warned_overflow
236
+ @warned_overflow = true
237
+ else
238
+ @buffer[key] = { host: key[0], first_segment: key[1], method: key[2], header_name: key[3], count: 1,
239
+ first_seen: at, last_seen: at }
240
+ if @buffer.size >= @flush_at
241
+ flush_now = true
242
+ else
243
+ arm = @timer.nil?
244
+ end
245
+ end
246
+ end
247
+ if warn_overflow
248
+ Warnings.warn_once("KNOXCALL_EGRESS_OBSERVATIONS_OVERFLOW",
249
+ "KnoxCall: more than #{@max_keys} distinct uncovered-egress observations are pending; " \
250
+ "new ones are dropped until the next flush.")
251
+ end
252
+ if flush_now
253
+ Thread.new { flush }.tap { |t| t.name = "knoxcall-egress-observations" }
254
+ elsif arm
255
+ arm_timer
256
+ end
257
+ nil
258
+ rescue StandardError
259
+ # best-effort: telemetry must never reach the application's request.
260
+ nil
261
+ end
262
+
263
+ # Send what is pending now, synchronously, inside the SDK's own suppressed
264
+ # scope so the report is never itself intercepted. A concurrent flush
265
+ # returns immediately. Never raises: a 403 ends reporting for good
266
+ # (warned once); any other failure drops the batch (warned once) and is
267
+ # never retried in a loop.
268
+ def flush
269
+ batch = nil
270
+ @mutex.synchronize do
271
+ return if @flushing || @forbidden || @buffer.empty?
272
+
273
+ @flushing = true
274
+ cancel_timer_locked
275
+ batch = @buffer.values.map { |e| wire(e) }
276
+ @buffer.clear
277
+ end
278
+ begin
279
+ suppressed { deliver(batch) }
280
+ ensure
281
+ @mutex.synchronize { @flushing = false }
282
+ end
283
+ nil
284
+ end
285
+
286
+ # Stop the timer and flush once more. Idempotent.
287
+ def stop
288
+ return if @stopped
289
+
290
+ @stopped = true
291
+ @mutex.synchronize { cancel_timer_locked }
292
+ flush
293
+ nil
294
+ end
295
+
296
+ private
297
+
298
+ def deliver(batch)
299
+ batch.each_slice(@max_per) do |chunk|
300
+ begin
301
+ res = @report.call(chunk)
302
+ rescue PermissionDeniedError
303
+ # The key lacks routes:read: reporting is off for good. Anything
304
+ # recorded from here on is dropped at the door.
305
+ @forbidden = true
306
+ @mutex.synchronize { @buffer.clear }
307
+ Warnings.warn_once("KNOXCALL_EGRESS_OBSERVATIONS_FORBIDDEN",
308
+ "KnoxCall: the credential cannot report uncovered-egress observations (HTTP 403 — it " \
309
+ "lacks routes:read); reporting is off for this interceptor. Grant the scope, or pass " \
310
+ "observe_uncovered: false to silence this.")
311
+ return
312
+ rescue StandardError => e
313
+ # best-effort: the batch is dropped, never retried in a loop, never
314
+ # surfaced into the application's own request.
315
+ unless @warned_failed
316
+ @warned_failed = true
317
+ Warnings.warn_once("KNOXCALL_EGRESS_OBSERVATIONS_FAILED",
318
+ "KnoxCall: reporting uncovered-egress observations failed (#{e.class}: #{e.message}); " \
319
+ "the batch was dropped.")
320
+ end
321
+ return
322
+ end
323
+ next unless @on_flush
324
+
325
+ begin
326
+ @on_flush.call(accepted: (res && (res["accepted"] || res[:accepted])).to_i,
327
+ dropped: (res && (res["dropped"] || res[:dropped])).to_i)
328
+ rescue StandardError
329
+ # A caller's hook must never break the reporter.
330
+ nil
331
+ end
332
+ end
333
+ end
334
+
335
+ def suppressed(&block)
336
+ if defined?(KnoxCall::InterceptContext)
337
+ KnoxCall::InterceptContext.suppressed(&block)
338
+ else
339
+ yield
340
+ end
341
+ end
342
+
343
+ def arm_timer
344
+ delay = [0.001, @interval * (1 + (@rand.call * 0.2 - 0.1))].max # ±10 %
345
+ @mutex.synchronize do
346
+ return if @stopped || @timer
347
+
348
+ @timer = Thread.new do
349
+ sleep delay
350
+ @mutex.synchronize { @timer = nil }
351
+ flush
352
+ rescue StandardError
353
+ nil
354
+ end
355
+ @timer.name = "knoxcall-egress-observations-timer"
356
+ end
357
+ end
358
+
359
+ def cancel_timer_locked
360
+ t = @timer
361
+ @timer = nil
362
+ t&.kill unless t.equal?(Thread.current)
363
+ end
364
+
365
+ def wire(e)
366
+ { host: e[:host], first_segment: e[:first_segment], method: e[:method], header_name: e[:header_name],
367
+ count: e[:count], first_seen: iso(e[:first_seen]), last_seen: iso(e[:last_seen]) }
368
+ end
369
+
370
+ def iso(ts) = Time.at(ts).utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
371
+ end
372
+ end
@@ -0,0 +1,304 @@
1
+ require "json"
2
+
3
+ module KnoxCall
4
+ class Error < StandardError; end
5
+
6
+ # Raised by credential auto-detection when NO usable credential is found —
7
+ # no explicit creds (flat options or bootstrap:), no KNOXCALL_ACCESS_TOKEN /
8
+ # KNOXCALL_API_KEY / KNOXCALL_CLIENT_ID + KNOXCALL_CLIENT_SECRET, and no
9
+ # `knoxcall login` credentials file. A subclass of the base KnoxCall::Error —
10
+ # this SDK's bootstrap error, as there is no separate BootstrapError class —
11
+ # so existing `rescue KnoxCall::Error` keeps working, but distinctly typed so
12
+ # callers can branch on "not logged in — offer KnoxCall.login" versus a
13
+ # genuine misconfiguration (PARITY §1/§14). Also raised by the interactive
14
+ # login helpers when prompting would be unsafe (no TTY / CI / opt-out).
15
+ class NotAuthenticatedError < Error; end
16
+
17
+ class APIError < Error
18
+ attr_reader :status_code, :headers, :body
19
+ # Machine-readable error code — the server envelope's `error.type` (Shape A)
20
+ # or the bare `error` code string (flat Shape C). +nil+ when the body carried
21
+ # no code. Branch on this rather than string-matching the message.
22
+ attr_reader :code
23
+ # Correlation id for KnoxCall server logs — the `X-Request-Id` response
24
+ # header when present, else the id carried in the body. +nil+ if neither.
25
+ attr_reader :request_id
26
+
27
+ def initialize(message, status_code, headers: nil, body: nil, code: nil, request_id: nil)
28
+ super("KnoxCall API error #{status_code}: #{message}")
29
+ @status_code = status_code
30
+ @headers = headers || {}
31
+ @body = body
32
+ @code = code
33
+ @request_id = request_id
34
+ end
35
+
36
+ # Alias for #code — the canonical /v1 envelope names this field `type`,
37
+ # so callers that think in server terms can read `e.type`.
38
+ def type = @code
39
+ end
40
+
41
+ class AuthenticationError < APIError; end
42
+ class PermissionDeniedError < APIError; end
43
+ # Deprecated: pre-release name (also shadows Ruby's ::PermissionError when
44
+ # the module is included). Use PermissionDeniedError. Remove before 2.0.
45
+ PermissionError = PermissionDeniedError
46
+
47
+ # 402 — a plan/billing limit was hit. Two error types share this status:
48
+ # "plan_limit" (a counted quota was reached) and "plan_feature" (the
49
+ # capability itself is not on the tier). The mapping is on STATUS, so both
50
+ # land here.
51
+ # Distinct from PermissionDeniedError (403) so a caller can show an
52
+ # "upgrade" prompt rather than an "access denied" one. Carries #code /
53
+ # #type / #request_id like the other typed errors.
54
+ class PaymentRequiredError < APIError; end
55
+
56
+ class NotFoundError < APIError; end
57
+
58
+ # 409 — the request conflicts with server state (e.g. a duplicate name, or an
59
+ # idempotency-key reuse with a different body). Retrying verbatim will not
60
+ # resolve it, so the client never replays a 409.
61
+ class ConflictError < APIError; end
62
+
63
+ # 422 — the request body failed server-side validation. When the server
64
+ # returns a per-field breakdown it is available via #fields.
65
+ class ValidationError < APIError
66
+ # Per-field validation messages ({field => [msg, ...]}) when the server
67
+ # supplied them, else +nil+.
68
+ def fields
69
+ @body.is_a?(Hash) ? @body["fields"] : nil
70
+ end
71
+ end
72
+
73
+ class RateLimitError < APIError
74
+ # Server-requested delay in seconds, if a Retry-After header was sent.
75
+ def retry_after
76
+ v = @headers["retry-after"]
77
+ v && v.to_f
78
+ end
79
+ end
80
+
81
+ class ServerError < APIError; end
82
+
83
+ # A refusal from the AI *data plane* (+POST {agent_url}/…+), typed.
84
+ #
85
+ # WHY IT IS ITS OWN CLASS. The data plane is not the Management API: it
86
+ # answers <tt>{error, error_description, code}</tt> with the machine-readable
87
+ # code in BOTH +error+ and +code+ (AIGW-163), while the management plane
88
+ # answers the nested <tt>{"error" => {"type", "message", "request_id"}}</tt>.
89
+ # A +code+ of "budget_exceeded" and a +code+ of "not_found" come from
90
+ # different contracts, and the status alone cannot tell them apart.
91
+ #
92
+ # The SDK does not make the data-plane call for you — that is the design: you
93
+ # point an existing provider client at the agent's +agent_url+ and it works
94
+ # unchanged. So this class is paired with KnoxCall.ai_gateway_error_from,
95
+ # which types whatever that client hands back.
96
+ #
97
+ # A subclass of APIError (and so of the base Error), so an existing
98
+ # <tt>rescue KnoxCall::APIError</tt> still catches it (PARITY §1).
99
+ class AIGatewayError < APIError
100
+ # The human sentence. Rewritten whenever a clearer wording is found —
101
+ # branch on #code, never on this.
102
+ attr_reader :error_description
103
+ # Whole seconds from +Retry-After+, or +nil+.
104
+ #
105
+ # Absence is meaningful: the gateway sends no header rather than a guess, so
106
+ # +nil+ means "back off on your own schedule", never "retry now".
107
+ attr_reader :retry_after
108
+
109
+ def initialize(message, status_code, error_description:, retry_after: nil, **kwargs)
110
+ super(message, status_code, **kwargs)
111
+ @error_description = error_description
112
+ @retry_after = retry_after
113
+ end
114
+ end
115
+
116
+ # Does this parsed body look like the AI data plane's envelope?
117
+ #
118
+ # +error+ and +code+ must both be present AND equal: the pre-AIGW-163 auth
119
+ # shape had a +code+ that was not the +error+, and accepting it would make
120
+ # +code+ mean two things again.
121
+ def self.ai_gateway_error_body?(body)
122
+ return false unless body.is_a?(Hash)
123
+
124
+ body["error"].is_a?(String) &&
125
+ body["code"].is_a?(String) &&
126
+ body["error_description"].is_a?(String) &&
127
+ body["error"] == body["code"]
128
+ end
129
+
130
+ # Type a refusal a provider client received from an agent's data-plane URL.
131
+ #
132
+ # Returns +nil+ when +body+ is not the data plane's envelope, so a caller
133
+ # falls through to its own handling rather than being handed a mislabelled
134
+ # error:
135
+ #
136
+ # res = Net::HTTP.post(URI("#{agent['agent_url']}/v1/messages"), payload)
137
+ # if res.code.to_i >= 400
138
+ # err = KnoxCall.ai_gateway_error_from(res.code.to_i, JSON.parse(res.body), res)
139
+ # sleep(err.retry_after || 60) if err&.code == "budget_exceeded"
140
+ # raise err || "HTTP #{res.code}"
141
+ # end
142
+ #
143
+ # +headers+ accepts a Hash or anything with +[]+ (a Net::HTTPResponse), or nil.
144
+ def self.ai_gateway_error_from(status, body, headers = nil)
145
+ return nil unless ai_gateway_error_body?(body)
146
+
147
+ read = lambda do |name|
148
+ next nil if headers.nil?
149
+
150
+ value = begin
151
+ headers[name] || headers[name.downcase]
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ value.is_a?(String) ? value : nil
156
+ end
157
+
158
+ raw_retry = (read.call("Retry-After") || "").strip
159
+ # An HTTP-date Retry-After is legal but is not delta-seconds; nil is the
160
+ # right reading of one we cannot use.
161
+ retry_after = raw_retry.match?(/\A\d+\z/) ? raw_retry.to_i : nil
162
+ request_id = read.call("X-Request-Id")
163
+ request_id = body["request_id"] if request_id.nil? && body["request_id"].is_a?(String)
164
+
165
+ AIGatewayError.new(
166
+ body["error_description"],
167
+ status,
168
+ error_description: body["error_description"],
169
+ retry_after: retry_after,
170
+ code: body["code"],
171
+ request_id: request_id,
172
+ headers: headers.is_a?(Hash) ? headers : nil,
173
+ body: body
174
+ )
175
+ end
176
+
177
+ # The token endpoint returned something unusable (non-JSON 200 from an edge
178
+ # proxy, missing access_token, a DPoP-bound token this SDK can't use, ...).
179
+ class TokenError < Error; end
180
+
181
+ # Transport-level failure — the request may never have reached the server.
182
+ class NetworkError < Error; end
183
+ class ConnectionTimeoutError < NetworkError; end
184
+
185
+ # A webhook delivery failed verification in Client.construct_event (missing
186
+ # header, signature mismatch, stale timestamp, non-JSON body). The message
187
+ # says what failed without ever echoing the signature or the secret.
188
+ class WebhookSignatureVerificationError < Error; end
189
+
190
+ # Raised by the wrap Faraday transport (wrap.faraday_connection) when a
191
+ # wrapped SDK's provider key contradicts the client's sandbox flag —
192
+ # both-must-agree (PARITY §18): a Stripe TEST key can never be wrapped by a
193
+ # LIVE client (or vice versa), and a publishable (pk_…) key is refused
194
+ # outright. Also raised for a malformed route-around host. A subclass of the
195
+ # base KnoxCall::Error so existing `rescue KnoxCall::Error` keeps working; it
196
+ # is a configuration mistake, never a transport failure.
197
+ class WrapSandboxMismatchError < Error; end
198
+
199
+ # KnoxCall.signup failed — validation, slug conflict, rate limit, or an
200
+ # unexpected response body. Carries the server's machine-readable error
201
+ # type (e.g. "slug_taken") and the request id for support.
202
+ class SignupError < Error
203
+ attr_reader :status_code, :error_type, :request_id, :body
204
+
205
+ def initialize(message, status_code:, error_type: nil, request_id: nil, body: nil)
206
+ super(message)
207
+ @status_code = status_code
208
+ @error_type = error_type
209
+ @request_id = request_id
210
+ @body = body
211
+ end
212
+ end
213
+
214
+ # KnoxCall.exchange_token failed — the RFC 8693 exchange at
215
+ # POST /v1/oauth/token was refused, or returned a body with no
216
+ # access_token. +error_type+ is the RFC 6749 §5.2 code: "invalid_grant",
217
+ # "invalid_target", "unsupported_grant_type", "invalid_request" or
218
+ # "server_error".
219
+ class TokenExchangeError < Error
220
+ attr_reader :status_code, :error_type, :body
221
+
222
+ def initialize(message, status_code:, error_type: nil, body: nil)
223
+ super(message)
224
+ @status_code = status_code
225
+ @error_type = error_type
226
+ @body = body
227
+ end
228
+ end
229
+
230
+ # A WorkloadCredentialProvider assertion source returned bytes that were
231
+ # already spent on a previous exchange, so sending them could only have been
232
+ # refused. This is a caller-side configuration error, not a credential
233
+ # rejection, and it says so: the message names the cause and what to do,
234
+ # because the alternative is a replay refusal from the server that reads like
235
+ # "your CI identity is not trusted".
236
+ class StaleAssertionError < Error; end
237
+
238
+ # Build the typed error for a >= 400 Net::HTTPResponse. Mirrors the Node
239
+ # SDK's errorFromResponse (sdk/knoxcall-node/src/error.ts).
240
+ def self.error_from_response(resp)
241
+ status = resp.code.to_i
242
+ data = begin
243
+ JSON.parse(resp.body.to_s)
244
+ rescue JSON::ParserError
245
+ nil
246
+ end
247
+
248
+ headers = {}
249
+ resp.each_header { |k, v| headers[k.downcase] = v }
250
+
251
+ body = data.is_a?(Hash) ? data : {}
252
+ err = body["error"]
253
+
254
+ # The KnoxCall /v1 API returns errors as {error:{type,message,request_id}}
255
+ # (the `error` value is a HASH) — Shape A, the canonical envelope. We stay
256
+ # tolerant of the flat shapes some non-/v1 surfaces still use:
257
+ # B: {error:"<message>", statusCode, errorId}
258
+ # C: {error:"<code>", message} — `error` is a CODE, `message` is human.
259
+ if err.is_a?(Hash)
260
+ msg = presence(err["message"]) || presence(err["type"]) || "HTTP #{status}"
261
+ code = err["type"].is_a?(String) ? err["type"] : nil
262
+ body_request_id = err["request_id"].is_a?(String) ? err["request_id"] : nil
263
+ else
264
+ # Flat shapes. Prefer a human `error_description`/`message` over the bare
265
+ # `error` (a code string in Shape C, a message in Shape B), so Shape C
266
+ # surfaces the human text — not the code — while still recording the code.
267
+ msg = presence(body["error_description"]) ||
268
+ presence(body["message"]) ||
269
+ presence(err) ||
270
+ "HTTP #{status}"
271
+ # AIGW-163: the AI data plane sends the code in BOTH `error` and `code`.
272
+ # Prefer the explicit `code` — a future surface could carry one that is
273
+ # not mirrored, and reading the mirror would silently lose it.
274
+ code = presence(body["code"]) || (err.is_a?(String) ? err : nil)
275
+ body_request_id =
276
+ (body["request_id"].is_a?(String) ? body["request_id"] : nil) ||
277
+ (body["errorId"].is_a?(String) ? body["errorId"] : nil)
278
+ end
279
+
280
+ # Correlation id: prefer the X-Request-Id response header (now always emitted
281
+ # by the API) then fall back to the id carried in the body.
282
+ request_id = headers["x-request-id"] || body_request_id
283
+
284
+ klass = case status
285
+ when 401 then AuthenticationError
286
+ when 402 then PaymentRequiredError
287
+ when 403 then PermissionDeniedError
288
+ when 404 then NotFoundError
289
+ when 409 then ConflictError
290
+ when 422 then ValidationError
291
+ when 429 then RateLimitError
292
+ when 500.. then ServerError
293
+ else APIError
294
+ end
295
+ klass.new(msg, status, headers: headers, body: data, code: code, request_id: request_id)
296
+ end
297
+
298
+ # A non-empty String, else nil — collapses "" and non-strings to nil so the
299
+ # message/code fallbacks skip them.
300
+ def self.presence(v)
301
+ v if v.is_a?(String) && !v.empty?
302
+ end
303
+ private_class_method :presence
304
+ end