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,181 @@
1
+ require "net/http"
2
+ require "knoxcall/errors"
3
+ require "knoxcall/intercept_pipeline"
4
+
5
+ module KnoxCall
6
+ # What {Resources::Wrap#intercept!} returns: the route-aware controls plus
7
+ # +uninstall+.
8
+ class InterceptHandle
9
+ attr_reader :pipeline
10
+
11
+ def initialize(pipeline)
12
+ @pipeline = pipeline
13
+ @active = true
14
+ end
15
+
16
+ def active? = @active
17
+
18
+ # Stop intercepting and drop the manifest. Ruby cannot un-prepend a module,
19
+ # so the seam stays on +Net::HTTP+ but calls +super+ immediately once no
20
+ # handle is active — every request goes exactly where it did before.
21
+ # Idempotent.
22
+ def uninstall
23
+ return unless @active
24
+
25
+ @active = false
26
+ @pipeline.stop
27
+ Intercept.clear(self)
28
+ nil
29
+ end
30
+ alias stop uninstall
31
+
32
+ def manifest = @pipeline.manifest
33
+ def ready = @pipeline.ready
34
+ def refresh = @pipeline.refresh
35
+ end
36
+
37
+ # The opt-in, EXPERIMENTAL process-wide seam (founder decision D7,
38
+ # 2026-09-25): a module prepended onto +Net::HTTP+ so that +Net::HTTP#request+
39
+ # — the one method every Net::HTTP verb, +Net::HTTP.get+, Faraday's default
40
+ # adapter, +rest-client+ and +httparty+ funnel through — consults the
41
+ # route-aware pipeline first. Not reached: Typhoeus / Curb / +http.rb+ (their
42
+ # own socket layer). One handle per process.
43
+ #
44
+ # This is a convenience, not a security boundary: it is a process global, it
45
+ # composes with other Net::HTTP patchers (WebMock, VCR, APM agents) in
46
+ # install order, and a request made while a handle is active is decided by
47
+ # the same table as every other seam. Route mode is the custody path — the
48
+ # key never enters your process.
49
+ module Intercept
50
+ @mutex = Mutex.new
51
+ @installed = nil
52
+ @prepended_on = nil
53
+
54
+ class << self
55
+ # The active handle, or nil.
56
+ def installed
57
+ @mutex.synchronize { @installed }
58
+ end
59
+
60
+ # Prepend the seam (once per +Net::HTTP+ class — the constant is re-read
61
+ # so a class swapped in later, WebMock-style, is covered) and activate the
62
+ # handle. A second install while one is active is refused.
63
+ def install(pipeline)
64
+ @mutex.synchronize do
65
+ raise Error, "wrap.intercept! is already installed in this process; uninstall the existing handle first" if @installed
66
+
67
+ target = ::Net::HTTP
68
+ unless @prepended_on.equal?(target)
69
+ target.prepend(NetHTTPRequest)
70
+ @prepended_on = target
71
+ end
72
+ @installed = InterceptHandle.new(pipeline)
73
+ end
74
+ end
75
+
76
+ def clear(handle)
77
+ @mutex.synchronize { @installed = nil if @installed.equal?(handle) }
78
+ end
79
+
80
+ # The full URL a +Net::HTTP+ instance + request pair addresses.
81
+ def url_for(http, req)
82
+ "#{origin_for(http)}#{req.path}"
83
+ end
84
+
85
+ # The origin a +Net::HTTP+ instance connects to, as a URL with no path
86
+ # (+http://host:port+) — what the seam can decide on BEFORE a request
87
+ # exists, at connect time.
88
+ def origin_for(http)
89
+ ssl = http.use_ssl?
90
+ scheme = ssl ? "https" : "http"
91
+ host = http.address.to_s
92
+ host = "[#{host}]" if host.include?(":") && !host.start_with?("[")
93
+ default = ssl ? 443 : 80
94
+ authority = http.port == default ? host : "#{host}:#{http.port}"
95
+ "#{scheme}://#{authority}"
96
+ end
97
+ end
98
+
99
+ # The prepended seam. +request+ is where every Net::HTTP call lands —
100
+ # but +Net::HTTP+ CONNECTS in +start+, before +request+ ever runs, so a
101
+ # request the seam is about to reroute would still open a TCP (and TLS)
102
+ # connection to the real upstream first, and fail outright when that host
103
+ # is unreachable from the process. Measured by the CI smoke (#1000): the
104
+ # echo lives on the runner's loopback, the container's loopback refuses,
105
+ # and +Net::HTTP.get_response+ died in +connect+ before KnoxCall was ever
106
+ # asked. So +connect+ is part of the seam too: a host the decision table
107
+ # would reroute defers its connection, and a request that ends up direct
108
+ # — unlisted, route-around, the kill switch, or the +unavailable: :direct+
109
+ # fallback — connects at that moment instead.
110
+ module NetHTTPRequest
111
+ def connect
112
+ handle = Intercept.installed
113
+ if !@knoxcall_connect_now && handle && handle.active? && !InterceptContext.suppressed? &&
114
+ !handle.pipeline.decide("#{Intercept.origin_for(self)}/", "GET").direct?
115
+ @knoxcall_connect_deferred = true
116
+ return
117
+ end
118
+ super
119
+ end
120
+ private :connect
121
+
122
+ def request(req, body = nil, &block)
123
+ handle = Intercept.installed
124
+ if handle.nil? || !handle.active? || InterceptContext.suppressed?
125
+ knoxcall_connect_if_deferred
126
+ return super
127
+ end
128
+
129
+ url = Intercept.url_for(self, req)
130
+ decision = handle.pipeline.decide(url, req.method)
131
+ if decision.direct?
132
+ direct_headers = {}
133
+ req.each_header { |k, v| direct_headers[k] = v }
134
+ handle.pipeline.direct_decided(decision, url, method: req.method, headers: direct_headers)
135
+ knoxcall_connect_if_deferred
136
+ return super
137
+ end
138
+
139
+ # The body is held in memory (Net::HTTP's own requirement for a resend
140
+ # too), so a resend after a routing refusal is replayable.
141
+ stream = req.body_stream
142
+ payload = body || req.body || stream&.read
143
+ headers = {}
144
+ req.each_header { |k, v| headers[k] = v }
145
+
146
+ resp = handle.pipeline.send(decision, url: url, method: req.method, headers: headers, body: payload)
147
+ if resp == InterceptPipeline::DIRECT
148
+ # Perform the ORIGINAL request ourselves — connecting now, since the
149
+ # connection was deferred for this host. A consumed body stream is
150
+ # replaced by its bytes so Net::HTTP can send them.
151
+ knoxcall_connect_if_deferred
152
+ if stream
153
+ req.body_stream = nil
154
+ req.body = payload
155
+ return super(req, nil, &block)
156
+ end
157
+ return super(req, body, &block)
158
+ end
159
+
160
+ yield resp if block_given?
161
+ resp
162
+ end
163
+
164
+ private
165
+
166
+ # Establish the connection +connect+ deferred, through the real
167
+ # +Net::HTTP#connect+, exactly once.
168
+ def knoxcall_connect_if_deferred
169
+ return unless @knoxcall_connect_deferred
170
+
171
+ @knoxcall_connect_deferred = false
172
+ @knoxcall_connect_now = true
173
+ begin
174
+ connect
175
+ ensure
176
+ @knoxcall_connect_now = false
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,455 @@
1
+ require "set"
2
+ require "uri"
3
+ require "knoxcall/errors"
4
+ require "knoxcall/warnings"
5
+ require "knoxcall/wrap_transport"
6
+ require "knoxcall/intercept_resolver"
7
+ require "knoxcall/intercept_store"
8
+ require "knoxcall/route_refusal"
9
+ require "knoxcall/egress_observations"
10
+
11
+ module KnoxCall
12
+ # Thread-local (fiber-local) flags the route-aware seams read.
13
+ #
14
+ # - +routed { }+ marks a call site: with +require_context: true+ only egress
15
+ # performed inside the block is intercepted — you mark the CALL SITE, not
16
+ # the SDK.
17
+ # - +suppressed { }+ marks the SDK's OWN traffic (the manifest poll, the
18
+ # route / ephemeral hop) so the process-wide Net::HTTP seam never
19
+ # re-intercepts it. The own-host rule already sends it direct; the flag
20
+ # makes that independent of URL parsing.
21
+ module InterceptContext
22
+ ROUTED = :knoxcall_intercept_routed
23
+ SUPPRESS = :knoxcall_intercept_suppress
24
+
25
+ module_function
26
+
27
+ def routed(&block) = with_flag(ROUTED, &block)
28
+ def routed? = Thread.current[ROUTED] ? true : false
29
+ def suppressed(&block) = with_flag(SUPPRESS, &block)
30
+ def suppressed? = Thread.current[SUPPRESS] ? true : false
31
+
32
+ def with_flag(key)
33
+ previous = Thread.current[key]
34
+ Thread.current[key] = true
35
+ yield
36
+ ensure
37
+ Thread.current[key] = previous
38
+ end
39
+ end
40
+
41
+ # The ONE route-aware pipeline behind every Ruby seam — the Faraday adapter
42
+ # ({Resources::Wrap#faraday_connection}), the Faraday middleware
43
+ # ({Resources::Wrap#faraday_middleware}) and the opt-in Net::HTTP seam
44
+ # ({Resources::Wrap#intercept!}) — so the decision table, the own-host
45
+ # refusal, the kill switch and the D4 failure policy apply identically
46
+ # (route-aware-interception-plan.md §2–§3, PARITY §21.1).
47
+ #
48
+ # Per request, first match wins ({InterceptResolver}): KNOXCALL_INTERCEPT=off
49
+ # → direct · unparseable → direct · the client's own hosts and any
50
+ # knoxcall.com host → direct · route-around rule → direct · +require_context+
51
+ # outside +routed { }+ → direct · a manifest entry covering host + path →
52
+ # ROUTE (the Route injects the stored secret; no provider credential travels)
53
+ # · a listed host → EPHEMERAL · otherwise direct.
54
+ #
55
+ # Route mode sends through the existing {Client#call} pipeline
56
+ # (x-knoxcall-route, the client's environment, retry + the one re-mint
57
+ # inherited) — never a second proxy implementation. A KnoxCall-origin 401 in
58
+ # route mode, after that re-mint, means a stale manifest or a refused
59
+ # credential, and a KnoxCall-origin 404 route_not_found means a stale manifest
60
+ # naming a Route that no longer resolves (PARITY §21; RouteRefusal): ONE
61
+ # forced refresh, ONE re-decision, a resend only when the decision changed
62
+ # (a routing refusal is answered before any upstream contact). Never a loop.
63
+ #
64
+ # Unavailability (D4): route mode and escrow fail CLOSED (the SDK's
65
+ # {NetworkError}); transit may opt into going direct (+unavailable: :direct+,
66
+ # transport-wide or per host) — the seam then performs the ORIGINAL request.
67
+ class InterceptPipeline
68
+ # Returned by {#send} when the seam must perform the original request
69
+ # itself: a D4 fallback, or a re-decision to direct after a refusal.
70
+ DIRECT = :direct
71
+
72
+ HOOKS = %i[on_reroute on_refresh on_manifest_error on_unmatched_path on_refused on_fallback
73
+ on_promoted on_route_around on_observation_flush].freeze
74
+
75
+ attr_reader :store, :rules, :observer
76
+
77
+ # @param client [KnoxCall::Client]
78
+ # @param all_hosts [Boolean] the explicit-transport form: every request is "listed"
79
+ # @param hosts [Array<String>, Hash{String => Hash}, nil] hosts to cover even when no Route does;
80
+ # a Hash carries per-host +credential:+ (escrow) / +unavailable:+ (:direct)
81
+ # @param routes [Symbol, String] +:auto+ consults the manifest; +:off+ never polls
82
+ # @param credential [Hash, nil] transport-wide escrow {secret:, scheme:}
83
+ # @param route [String, nil] legacy explicit route slug (every non-direct request goes via it)
84
+ # @param auto_switch [Boolean] legacy promoted-route memory (only consulted with routes: :off)
85
+ # @param route_around [Array<Hash>, nil] extra route-around rules
86
+ # @param disable_default_route_around [Boolean]
87
+ # @param require_context [Boolean] only intercept inside +routed { }+
88
+ # @param unavailable [Symbol] +:error+ (fail closed, default) or +:direct+ (transit only)
89
+ # @param manifest_fetch [#call, nil] test seam: replaces the manifest call
90
+ # @param observe_uncovered [Boolean] report uncovered egress (PARITY §21.3) — see
91
+ # {Resources::Wrap#faraday_connection}; +false+ or KNOXCALL_OBSERVE_UNCOVERED=off turns it off
92
+ # @param observation_report [#call, nil] test seam: replaces the report call
93
+ # @param hooks [Hash{Symbol => #call}] see HOOKS (+on_observation_flush+: +{accepted:, dropped:}+)
94
+ def initialize(client:, all_hosts: false, hosts: nil, routes: :off, credential: nil, route: nil,
95
+ auto_switch: false, route_around: nil, disable_default_route_around: false,
96
+ require_context: false, unavailable: :error, manifest_fetch: nil,
97
+ observe_uncovered: true, observation_report: nil, **hooks)
98
+ unknown = hooks.keys - HOOKS
99
+ raise ArgumentError, "unknown intercept option(s): #{unknown.join(', ')}" unless unknown.empty?
100
+
101
+ @client = client
102
+ @all_hosts = all_hosts ? true : false
103
+ @hosts = Set.new
104
+ @host_options = {}
105
+ add_hosts!(hosts)
106
+ self.class.validate_credential!(credential, "wrap")
107
+ @credential = credential
108
+ @route = route.is_a?(String) && !route.empty? ? route : nil
109
+ @auto_switch = auto_switch ? true : false
110
+ WrapTransport.assert_route_around_rules(route_around) if route_around
111
+ @rules = (disable_default_route_around ? [] : WrapTransport::DEFAULT_ROUTE_AROUND) + Array(route_around)
112
+ @require_context = require_context ? true : false
113
+ @unavailable = unavailable.to_s == "direct" ? :direct : :error
114
+ @hooks = hooks
115
+ @auto_switched = {}
116
+ @unmatched_warned = Set.new
117
+ @mutex = Mutex.new
118
+ @store =
119
+ if routes.to_s == "auto"
120
+ InterceptManifestStore.new(manifest_fetch || method(:fetch_manifest),
121
+ on_refresh: method(:handle_refresh),
122
+ on_error: hooks[:on_manifest_error])
123
+ end
124
+
125
+ # Uncovered-egress observations (PARITY §21.3). ON by default (founder
126
+ # decision 2026-09-26) for the process-wide seam and the middleware
127
+ # (+all_hosts: false+) and for an explicit connection with
128
+ # +routes: :auto+; +observe_uncovered: false+ or the environment turns it
129
+ # off. An explicit connection treats every host as listed, so +:unlisted+
130
+ # never occurs there by construction — the reporter exists so the
131
+ # contract (and the opt-out) reads the same in every form. The report
132
+ # rides the SDK's own credential through +request+ under the suppress
133
+ # flag, so it is never itself intercepted.
134
+ @observer = nil
135
+ if observe_uncovered && !EgressObservations.disabled_by_env? && (!@all_hosts || routes.to_s == "auto")
136
+ report = observation_report || ->(observations) { @client.wrap.report_egress_observations(observations) }
137
+ @observer = EgressObservationReporter.new(report, on_flush: hooks[:on_observation_flush])
138
+ end
139
+ end
140
+
141
+ # A malformed credential must NOT silently fall through to transit mode and
142
+ # leak the SDK's raw key — fail loud (mirrors the Node fetch() guard).
143
+ def self.validate_credential!(credential, where)
144
+ return if credential.nil?
145
+
146
+ secret = credential.is_a?(Hash) ? (credential[:secret] || credential["secret"]) : nil
147
+ return if secret.is_a?(String) && !secret.empty?
148
+
149
+ raise TypeError,
150
+ "#{where} credential must be { secret: <non-empty String> } for escrow mode; " \
151
+ "omit `credential` entirely for transit mode."
152
+ end
153
+
154
+ # The manifest this pipeline is deciding on, or nil (routes off / not loaded).
155
+ def manifest = @store&.manifest
156
+
157
+ # Load the manifest now if it is stale (the first attempt included). Never
158
+ # raises for a manifest failure; returns the manifest or nil.
159
+ def ready
160
+ return nil if @store.nil?
161
+
162
+ InterceptContext.suppressed { @store.ensure }
163
+ end
164
+
165
+ # Refresh the manifest now (no-op with routes off).
166
+ def refresh
167
+ return nil if @store.nil?
168
+
169
+ InterceptContext.suppressed { @store.refresh("manual", force: true) }
170
+ end
171
+
172
+ # Stop polling and drop the manifest (and flush the uncovered-egress
173
+ # reporter once more); the pipeline keeps working on the ephemeral path
174
+ # for listed hosts and direct otherwise.
175
+ def stop
176
+ @store&.stop
177
+ @observer&.stop
178
+ nil
179
+ end
180
+
181
+ # Apply the decision table to one request (refreshing the manifest first
182
+ # when it is stale).
183
+ def decide(url, method)
184
+ manifest = @store && InterceptContext.suppressed { @store.ensure }
185
+ InterceptResolver.resolve(
186
+ url: url, method: method,
187
+ hosts: @all_hosts ? :all : @hosts,
188
+ manifest: manifest,
189
+ own_hosts: own_hosts,
190
+ route_around: @rules,
191
+ kill_switch: InterceptResolver.kill_switch?,
192
+ require_context: @require_context,
193
+ in_context: InterceptContext.routed?
194
+ )
195
+ end
196
+
197
+ # A direct decision (a seam calls this before performing the original
198
+ # request itself): fires the route-around hook, and — for +:unlisted+
199
+ # only — records an uncovered-egress observation when the request carries
200
+ # a credential-bearing header (PARITY §21.3). Observed AFTER the decision,
201
+ # BEFORE the direct send; never raises into the application's request.
202
+ def direct_decided(decision, url, method: nil, headers: nil)
203
+ if decision.reason == :route_around
204
+ fire(:on_route_around, url: url, host: decision.host, reason: decision.route_around_reason)
205
+ return
206
+ end
207
+ return unless decision.reason == :unlisted && @observer
208
+
209
+ begin
210
+ obs = EgressObservations.observation_for(url, method, headers)
211
+ @observer.record(obs) if obs
212
+ rescue StandardError
213
+ # best-effort: telemetry must never reach the application's request.
214
+ nil
215
+ end
216
+ end
217
+
218
+ # Send a non-direct decision through KnoxCall. Returns the
219
+ # +Net::HTTPResponse+ from the route or ephemeral hop, or {DIRECT} when the
220
+ # seam must perform the original request itself. +headers+ is the wrapped
221
+ # SDK's request-header Hash (any casing); +body+ the request body (String
222
+ # or nil — held in memory, so a resend after a routing refusal is
223
+ # replayable by construction).
224
+ def send(decision, url:, method:, headers:, body:)
225
+ InterceptContext.suppressed do
226
+ method = method.to_s.upcase
227
+ forwardable = WrapTransport.forwardable_headers(headers)
228
+ auth = header_value(headers, "Authorization")
229
+
230
+ # Legacy explicit route: / auto-switch memory (pre-manifest callers):
231
+ # every non-direct request goes via that slug with the full path.
232
+ legacy = legacy_route_for(decision)
233
+ if legacy
234
+ fire(:on_reroute, host: decision.host, url: url, mode: :route, slug: legacy, reason: :explicit_route)
235
+ return send_route(legacy, full_path(url), method, forwardable, body)
236
+ end
237
+
238
+ return send_route_with_refresh(decision, url, method, forwardable, auth, body) if decision.route?
239
+
240
+ # Ephemeral.
241
+ if decision.reason == :no_base_path_match
242
+ key = "#{decision.host} #{first_segment(url)}"
243
+ first = @mutex.synchronize { @unmatched_warned.add?(key) }
244
+ fire(:on_unmatched_path, host: decision.host, url: url) if first
245
+ end
246
+ fire(:on_reroute, host: decision.host, url: url, mode: :ephemeral, reason: decision.reason)
247
+ send_ephemeral(decision, url, method, forwardable, auth, body)
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ def add_hosts!(hosts)
254
+ return if hosts.nil?
255
+
256
+ entries = hosts.is_a?(Hash) ? hosts : Array(hosts).to_h { |h| [h, {}] }
257
+ entries.each do |host, opts|
258
+ assert_bare_host!(host)
259
+ opts = (opts || {}).to_h { |k, v| [k.to_sym, v] }
260
+ self.class.validate_credential!(opts[:credential], "intercept host #{host}")
261
+ n = WrapTransport.normalize_host(host)
262
+ @hosts << n
263
+ @host_options[n] = {
264
+ credential: opts[:credential],
265
+ unavailable: opts[:unavailable].to_s == "direct" ? :direct : nil
266
+ }
267
+ end
268
+ end
269
+
270
+ # A listed host that is not a bare DNS hostname (a scheme/port/path slipped
271
+ # in) could never match a parsed request host and would silently disable
272
+ # the listing — fail loud instead.
273
+ def assert_bare_host!(host)
274
+ h = host.to_s.strip
275
+ parsed = begin
276
+ URI.parse("https://#{h}").host.to_s
277
+ rescue URI::InvalidURIError
278
+ ""
279
+ end
280
+ return unless h.empty? || WrapTransport.normalize_host(parsed) != WrapTransport.normalize_host(h)
281
+
282
+ raise WrapSandboxMismatchError,
283
+ "invalid intercept host #{host.inspect}: expected a bare DNS hostname (no scheme, port, or path)."
284
+ end
285
+
286
+ # The client's management and data-plane hosts, never intercepted.
287
+ def own_hosts
288
+ out = Set.new
289
+ [@client.base_url, @client.proxy_base_url].each do |raw|
290
+ next if raw.nil? || raw.to_s.empty?
291
+
292
+ h = begin
293
+ WrapTransport.normalize_host(URI.parse(raw.to_s).host)
294
+ rescue URI::InvalidURIError
295
+ ""
296
+ end
297
+ out << h unless h.empty?
298
+ end
299
+ out
300
+ end
301
+
302
+ # The store's fetch: the client's environment, and the held version as
303
+ # If-None-Match (a 304 comes back as nil — PARITY §21.1).
304
+ def fetch_manifest(if_none_match: nil)
305
+ @client.wrap.intercept_manifest(environment: @client.environment, if_none_match: if_none_match)
306
+ end
307
+
308
+ # Warn once per entry that will be refused or is ambiguous, then forward
309
+ # to the caller's hook.
310
+ def handle_refresh(info)
311
+ info[:added].each do |e|
312
+ slug = e["slug"]
313
+ host_base = "#{e['host']}#{e['base_path']}"
314
+ if e["requires_clients"]
315
+ Warnings.warn_once(
316
+ "KNOXCALL_INTERCEPT_REQUIRES_CLIENTS:#{slug}",
317
+ "KnoxCall route #{slug.inspect} (#{host_base}) requires a registered client; a bearer-only SDK " \
318
+ "call will be refused (403). Register this process as a client of the route, or leave the " \
319
+ "route out of interception."
320
+ )
321
+ end
322
+ next unless e["ambiguous"]
323
+
324
+ Warnings.warn_once(
325
+ "KNOXCALL_INTERCEPT_AMBIGUOUS:#{host_base}",
326
+ "KnoxCall: more than one intercept-enabled route covers #{host_base}; the lexically lowest slug " \
327
+ "is used. Disable the others."
328
+ )
329
+ end
330
+ fire(:on_refresh, **info)
331
+ end
332
+
333
+ def legacy_route_for(decision)
334
+ return @route if @route
335
+ return nil unless decision.ephemeral? && @auto_switch
336
+
337
+ @mutex.synchronize { @auto_switched[decision.host] }
338
+ end
339
+
340
+ # Every route-mode reroute is marked (PARITY §21.2) — the manifest decision
341
+ # and the legacy explicit +route:+ form alike: both are a third-party SDK's
342
+ # call this pipeline redirected, which is what the API Log's "SDK intercept"
343
+ # origin means.
344
+ def send_route(slug, path, method, headers, body)
345
+ @client.call(slug, method: method, path: path, headers: headers, body: body,
346
+ _origin: Client::SDK_INTERCEPT_ORIGIN)
347
+ end
348
+
349
+ def send_route_with_refresh(decision, url, method, headers, auth, body)
350
+ fire(:on_reroute, host: decision.host, url: url, mode: :route, slug: decision.slug, reason: decision.reason)
351
+ resp = send_route(decision.slug, decision.path, method, headers, body)
352
+ return resp unless @store && route_refusal?(resp)
353
+
354
+ @store.refresh("route_refused", force: true)
355
+ again = decide(url, method)
356
+ changed = !again.route? || again.slug != decision.slug || again.path != decision.path
357
+ fire(:on_refused, host: decision.host, url: url, slug: decision.slug, status: resp.code.to_i,
358
+ redecided: changed ? again.mode : nil)
359
+ return resp unless changed
360
+
361
+ case again.mode
362
+ when :route
363
+ fire(:on_reroute, host: again.host, url: url, mode: :route, slug: again.slug, reason: again.reason)
364
+ send_route(again.slug, again.path, method, headers, body)
365
+ when :ephemeral
366
+ fire(:on_reroute, host: again.host, url: url, mode: :ephemeral, reason: again.reason)
367
+ send_ephemeral(again, url, method, headers, auth, body)
368
+ else
369
+ DIRECT
370
+ end
371
+ end
372
+
373
+ def send_ephemeral(decision, url, method, headers, auth, body)
374
+ host_opts = @host_options[decision.host] || {}
375
+ credential = host_opts[:credential] || @credential
376
+ opts = { method: method, body: body, headers: headers, mode: "transparent" }
377
+ if credential
378
+ # Escrow mode — the raw key never travels; the SDK's own key is an
379
+ # ignored placeholder (no Test/Live assertion).
380
+ opts[:upstream_auth_secret] = credential_field(credential, :secret)
381
+ scheme = credential_field(credential, :scheme)
382
+ opts[:upstream_auth_scheme] = scheme unless scheme.nil?
383
+ else
384
+ # Transit mode — lift the SDK's own Authorization header out-of-band.
385
+ WrapTransport.assert_key_matches_sandbox(auth, @client.sandbox)
386
+ opts[:upstream_authorization] = auth unless auth.nil?
387
+ end
388
+
389
+ begin
390
+ resp = @client.ephemeral(url, **opts)
391
+ rescue NetworkError => e
392
+ # D4: fail closed by default. Going direct is honoured only for TRANSIT
393
+ # traffic — the key is in the process there. Escrow has nothing to go
394
+ # direct with.
395
+ policy = host_opts[:unavailable] || @unavailable
396
+ raise unless policy == :direct && credential.nil?
397
+
398
+ fire(:on_fallback, host: decision.host, url: url, error: e)
399
+ return DIRECT
400
+ end
401
+
402
+ # Promoted-route hint: a Route now covers this host. With a manifest the
403
+ # hint is a signal to refresh it — the manifest is the truth. Without one
404
+ # (legacy auto_switch), remember the slug directly.
405
+ slug = resp["x-knox-promoted-route"]
406
+ if slug && !decision.host.empty?
407
+ fire(:on_promoted, host: decision.host, slug: slug)
408
+ if @store
409
+ @store.hint
410
+ elsif @auto_switch
411
+ @mutex.synchronize { @auto_switched[decision.host] = slug }
412
+ end
413
+ end
414
+ resp
415
+ end
416
+
417
+ # A KnoxCall-origin refusal on the route data plane: a 401 with neither
418
+ # spelling of "the upstream answered", or a 404 whose envelope error.type is
419
+ # route_not_found (PARITY §21.1; the predicate and its cross-language
420
+ # fixtures live in KnoxCall::RouteRefusal).
421
+ def route_refusal?(resp)
422
+ RouteRefusal.refusal?(status: resp.code.to_i, headers: resp.to_hash, body: resp.body)
423
+ end
424
+
425
+ def full_path(url)
426
+ u = URI.parse(url)
427
+ path = u.path.to_s.empty? ? "/" : u.path
428
+ u.query ? "#{path}?#{u.query}" : path
429
+ end
430
+
431
+ def first_segment(url)
432
+ path = begin
433
+ URI.parse(url).path.to_s
434
+ rescue URI::InvalidURIError
435
+ ""
436
+ end
437
+ "/#{path.delete_prefix('/').split('/', 2).first}"
438
+ end
439
+
440
+ def header_value(headers, name)
441
+ pair = headers.find { |k, _| k.to_s.casecmp(name).zero? }
442
+ pair&.last
443
+ end
444
+
445
+ def credential_field(credential, key)
446
+ return nil unless credential.is_a?(Hash)
447
+
448
+ credential.key?(key) ? credential[key] : credential[key.to_s]
449
+ end
450
+
451
+ def fire(hook, **info)
452
+ @hooks[hook]&.call(**info)
453
+ end
454
+ end
455
+ end