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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- 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
|