Module: KnoxCall
- Defined in:
- lib/knoxcall.rb,
lib/knoxcall/cli.rb,
lib/knoxcall/dpop.rb,
lib/knoxcall/ulid.rb,
lib/knoxcall/login.rb,
lib/knoxcall/cli/ai.rb,
lib/knoxcall/client.rb,
lib/knoxcall/errors.rb,
lib/knoxcall/signup.rb,
lib/knoxcall/cli/init.rb,
lib/knoxcall/warnings.rb,
lib/knoxcall/bootstrap.rb,
lib/knoxcall/cli/login.rb,
lib/knoxcall/cli/common.rb,
lib/knoxcall/cli/logout.rb,
lib/knoxcall/cli/whoami.rb,
lib/knoxcall/bound_route.rb,
lib/knoxcall/resources/pki.rb,
lib/knoxcall/route_refusal.rb,
lib/knoxcall/cli/ai_control.rb,
lib/knoxcall/resources/logs.rb,
lib/knoxcall/resources/wrap.rb,
lib/knoxcall/token_exchange.rb,
lib/knoxcall/wrap_transport.rb,
lib/knoxcall/intercept_patch.rb,
lib/knoxcall/intercept_store.rb,
lib/knoxcall/resources/roles.rb,
lib/knoxcall/credentials_file.rb,
lib/knoxcall/resources/agents.rb,
lib/knoxcall/resources/crypto.rb,
lib/knoxcall/resources/routes.rb,
lib/knoxcall/resources/vaults.rb,
lib/knoxcall/resources/account.rb,
lib/knoxcall/resources/clients.rb,
lib/knoxcall/resources/secrets.rb,
lib/knoxcall/workload_provider.rb,
lib/knoxcall/intercept_pipeline.rb,
lib/knoxcall/intercept_resolver.rb,
lib/knoxcall/resources/api_keys.rb,
lib/knoxcall/resources/webhooks.rb,
lib/knoxcall/egress_observations.rb,
lib/knoxcall/resources/workflows.rb,
lib/knoxcall/resources/ai_gateway.rb,
lib/knoxcall/resources/audit_logs.rb,
lib/knoxcall/resources/dynamic_db.rb,
lib/knoxcall/wrap_faraday_adapter.rb,
lib/knoxcall/resources/environments.rb,
lib/knoxcall/resources/oauth_clients.rb,
lib/knoxcall/resources/opportunities.rb,
lib/knoxcall/wrap_faraday_middleware.rb,
lib/knoxcall/resources/unwraps_envelope.rb
Defined Under Namespace
Modules: CLI, CredentialsFile, EgressObservations, Intercept, InterceptContext, InterceptResolver, Resources, RouteRefusal, ULID, Warnings, WrapTransport Classes: AIGatewayError, APIError, AccessToken, AuthenticationError, BoundRoute, Client, ClientCredentials, ConflictError, ConnectionTimeoutError, DpopKeyPair, EgressObservationReporter, Error, InterceptHandle, InterceptManifestStore, InterceptPipeline, NetworkError, NotAuthenticatedError, NotFoundError, OIDCTokenExchange, PaymentRequiredError, PermissionDeniedError, RateLimitError, ServerError, SignupError, StaleAssertionError, StoredCredentials, TokenError, TokenExchangeError, ValidationError, WebhookSignatureVerificationError, WorkloadCredentialProvider, WrapSandboxMismatchError
Constant Summary collapse
- VERSION =
"0.1.0"- SDK_VERSION =
"knoxcall-ruby/#{VERSION}"- DEFAULT_API_BASE =
"https://api.knoxcall.com"- DEFAULT_CLOUD_HOST =
"knoxcall.com"- PermissionError =
Deprecated: pre-release name (also shadows Ruby's ::PermissionError when the module is included). Use PermissionDeniedError. Remove before 2.0.
PermissionDeniedError- AccessTokenBootstrap =
Deprecated aliases — the pre-release *Bootstrap names used by the other KnoxCall SDKs. Remove before 2.0.
AccessToken- OidcTokenExchangeBootstrap =
OIDCTokenExchange- ClientCredentialsBootstrap =
ClientCredentials- TOKEN_EXCHANGE_GRANT =
The only grant_type POST /v1/oauth/token accepts.
"urn:ietf:params:oauth:grant-type:token-exchange".freeze
- ID_TOKEN_TYPE =
The only subject_token_type it accepts.
"urn:ietf:params:oauth:token-type:id_token".freeze
- KNOXCALL_AUDIENCE =
The default (and only supported) audience.
"knoxcall:gateway".freeze
- TENANT_SLUG_RE =
A tenant slug becomes a hostname, so it must be a bare DNS label: a slug adopted from config or an environment variable that is not one ("evil.com#") would send the workload OIDC token to an attacker host.
/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.freeze
Class Method Summary collapse
-
._signup_post(path, payload, what, base_url, timeout) ⇒ Object
private
The shared credential-less POST.
-
.ai_gateway_error_body?(body) ⇒ Boolean
Does this parsed body look like the AI data plane's envelope?.
-
.ai_gateway_error_from(status, body, headers = nil) ⇒ Object
Type a refusal a provider client received from an agent's data-plane URL.
-
.claim_signup(claim_handle, base_url: nil, timeout: 30) ⇒ Hash
Poll a claim handle returned by KnoxCall.signup.
-
.ensure_login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) ⇒ KnoxCall::Client
Return a client from an already-stored credential for the profile when one is present (no prompt, no network), otherwise run the interactive #login once.
-
.error_from_response(resp) ⇒ Object
Build the typed error for a >= 400 Net::HTTPResponse.
-
.exchange_base_url(tenant, sandbox, base_url) ⇒ Object
The data-plane origin for KnoxCall.exchange_token.
-
.exchange_token(subject_token:, resource: nil, audience: KNOXCALL_AUDIENCE, tenant: nil, sandbox: false, base_url: nil, timeout: 30) ⇒ Hash
Exchange a CI OIDC token for a short-lived AI-gateway capability token (RFC 8693 token exchange, AIGW-26).
-
.login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) ⇒ KnoxCall::Client
Run the interactive browser (loopback) or device-code login, persist the credential to ~/.knoxcall/credentials.json, and return a ready client bound to the written profile.
-
.signup(input, base_url: nil, timeout: 30) ⇒ Hash
Start creating a KnoxCall account.
Class Method Details
._signup_post(path, payload, what, base_url, timeout) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
The shared credential-less POST. Note what it does NOT treat as an error:
a 202. Both endpoints use it for a normal, credential-less success, so any
sub-400 response carrying a data object is returned unchanged.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/knoxcall/signup.rb', line 33 def self._signup_post(path, payload, what, base_url, timeout) uri = URI.parse((base_url || DEFAULT_API_BASE).chomp("/") + path) req = Net::HTTP::Post.new(uri) req["Content-Type"] = "application/json" req["Accept"] = "application/json" req["User-Agent"] = SDK_VERSION req.body = JSON.generate(payload) resp = begin http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" http.open_timeout = timeout http.read_timeout = timeout http.start { |h| h.request(req) } rescue Net::OpenTimeout, Net::ReadTimeout => e raise ConnectionTimeoutError, "#{what} request timed out: #{e.message}" rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e raise NetworkError, "#{what} request failed: #{e.class}: #{e.message}" end parsed = begin JSON.parse(resp.body.to_s) rescue JSON::ParserError nil end status = resp.code.to_i data = parsed.is_a?(Hash) && parsed["data"].is_a?(Hash) ? parsed["data"] : nil if status >= 400 || data.nil? err = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : {} = err["message"].is_a?(String) ? err["message"] : "#{what} failed with status #{status}" request_id = err["request_id"] || resp["x-request-id"] raise SignupError.new( , status_code: status, error_type: err["type"].is_a?(String) ? err["type"] : nil, request_id: request_id.is_a?(String) ? request_id : nil, body: parsed ) end data end |
.ai_gateway_error_body?(body) ⇒ Boolean
Does this parsed body look like the AI data plane's envelope?
error and code must both be present AND equal: the pre-AIGW-163 auth
shape had a code that was not the error, and accepting it would make
code mean two things again.
121 122 123 124 125 126 127 128 |
# File 'lib/knoxcall/errors.rb', line 121 def self.ai_gateway_error_body?(body) return false unless body.is_a?(Hash) body["error"].is_a?(String) && body["code"].is_a?(String) && body["error_description"].is_a?(String) && body["error"] == body["code"] end |
.ai_gateway_error_from(status, body, headers = nil) ⇒ Object
Type a refusal a provider client received from an agent's data-plane URL.
Returns nil when body is not the data plane's envelope, so a caller
falls through to its own handling rather than being handed a mislabelled
error:
res = Net::HTTP.post(URI("#{agent['agent_url']}/v1/messages"), payload)
if res.code.to_i >= 400
err = KnoxCall.ai_gateway_error_from(res.code.to_i, JSON.parse(res.body), res)
sleep(err.retry_after || 60) if err&.code == "budget_exceeded"
raise err || "HTTP #{res.code}"
end
headers accepts a Hash or anything with [] (a Net::HTTPResponse), or nil.
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/knoxcall/errors.rb', line 144 def self.ai_gateway_error_from(status, body, headers = nil) return nil unless ai_gateway_error_body?(body) read = lambda do |name| next nil if headers.nil? value = begin headers[name] || headers[name.downcase] rescue StandardError nil end value.is_a?(String) ? value : nil end raw_retry = (read.call("Retry-After") || "").strip # An HTTP-date Retry-After is legal but is not delta-seconds; nil is the # right reading of one we cannot use. retry_after = raw_retry.match?(/\A\d+\z/) ? raw_retry.to_i : nil request_id = read.call("X-Request-Id") request_id = body["request_id"] if request_id.nil? && body["request_id"].is_a?(String) AIGatewayError.new( body["error_description"], status, error_description: body["error_description"], retry_after: retry_after, code: body["code"], request_id: request_id, headers: headers.is_a?(Hash) ? headers : nil, body: body ) end |
.claim_signup(claim_handle, base_url: nil, timeout: 30) ⇒ Hash
Poll a claim handle returned by signup.
Returns {"status" => "pending", ...} — a 202, and a normal SUCCESS — until the emailed sign-in link has been clicked, then once returns {"status" => "ready", ...} with the tenant and a one-time Test-mode API key. Polling again after that raises SignupError (409); an unknown or expired handle raises it with 404.
Do not poll faster than the poll_after_seconds that signup returned.
119 120 121 |
# File 'lib/knoxcall/signup.rb', line 119 def self.claim_signup(claim_handle, base_url: nil, timeout: 30) _signup_post("/v1/signup/claim", { claim_handle: claim_handle }, "Signup claim", base_url, timeout) end |
.ensure_login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) ⇒ KnoxCall::Client
Return a client from an already-stored credential for the profile when one is present (no prompt, no network), otherwise run the interactive #login once. The ergonomic "make sure I'm authenticated, then give me a client" entry point. Accepts the same options as #login.
84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/knoxcall/login.rb', line 84 def ensure_login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) resolved_profile = CredentialsFile.resolve_profile(profile) if CredentialsFile.profile_available?(CredentialsFile.resolve_path, resolved_profile) return client_from_profile(resolved_profile, sandbox, ) end login(tenant: tenant, sandbox: sandbox, base_url: base_url, profile: profile, mode: mode, timeout: timeout, allow_non_interactive: allow_non_interactive, open_browser: open_browser, client_options: ) end |
.error_from_response(resp) ⇒ Object
Build the typed error for a >= 400 Net::HTTPResponse. Mirrors the Node SDK's errorFromResponse (sdk/knoxcall-node/src/error.ts).
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/knoxcall/errors.rb', line 240 def self.error_from_response(resp) status = resp.code.to_i data = begin JSON.parse(resp.body.to_s) rescue JSON::ParserError nil end headers = {} resp.each_header { |k, v| headers[k.downcase] = v } body = data.is_a?(Hash) ? data : {} err = body["error"] # The KnoxCall /v1 API returns errors as {error:{type,message,request_id}} # (the `error` value is a HASH) — Shape A, the canonical envelope. We stay # tolerant of the flat shapes some non-/v1 surfaces still use: # B: {error:"<message>", statusCode, errorId} # C: {error:"<code>", message} — `error` is a CODE, `message` is human. if err.is_a?(Hash) msg = presence(err["message"]) || presence(err["type"]) || "HTTP #{status}" code = err["type"].is_a?(String) ? err["type"] : nil body_request_id = err["request_id"].is_a?(String) ? err["request_id"] : nil else # Flat shapes. Prefer a human `error_description`/`message` over the bare # `error` (a code string in Shape C, a message in Shape B), so Shape C # surfaces the human text — not the code — while still recording the code. msg = presence(body["error_description"]) || presence(body["message"]) || presence(err) || "HTTP #{status}" # AIGW-163: the AI data plane sends the code in BOTH `error` and `code`. # Prefer the explicit `code` — a future surface could carry one that is # not mirrored, and reading the mirror would silently lose it. code = presence(body["code"]) || (err.is_a?(String) ? err : nil) body_request_id = (body["request_id"].is_a?(String) ? body["request_id"] : nil) || (body["errorId"].is_a?(String) ? body["errorId"] : nil) end # Correlation id: prefer the X-Request-Id response header (now always emitted # by the API) then fall back to the id carried in the body. request_id = headers["x-request-id"] || body_request_id klass = case status when 401 then AuthenticationError when 402 then PaymentRequiredError when 403 then PermissionDeniedError when 404 then NotFoundError when 409 then ConflictError when 422 then ValidationError when 429 then RateLimitError when 500.. then ServerError else APIError end klass.new(msg, status, headers: headers, body: data, code: code, request_id: request_id) end |
.exchange_base_url(tenant, sandbox, base_url) ⇒ Object
The data-plane origin for exchange_token. There is no default.
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/knoxcall/token_exchange.rb', line 149 def self.exchange_base_url(tenant, sandbox, base_url) return base_url.chomp("/") if base_url && !base_url.empty? if tenant.nil? || tenant.empty? raise ArgumentError, "exchange_token needs a tenant slug or a base_url: POST /v1/oauth/token is " \ "served only on the tenant data-plane host " \ "(https://{tenant}.knoxcall.com). Pointing it at api.knoxcall.com answers " \ "401, which reads like a rejected subject_token but means the endpoint is " \ "not there." end unless TENANT_SLUG_RE.match?(tenant) raise ArgumentError, "invalid tenant slug #{tenant.inspect} - expected a DNS label; refusing to " \ "send a subject token to a host derived from it" end host = sandbox ? "sandbox-#{tenant}" : tenant "https://#{host}.knoxcall.com" end |
.exchange_token(subject_token:, resource: nil, audience: KNOXCALL_AUDIENCE, tenant: nil, sandbox: false, base_url: nil, timeout: 30) ⇒ Hash
Exchange a CI OIDC token for a short-lived AI-gateway capability token (RFC 8693 token exchange, AIGW-26).
res = KnoxCall.exchange_token(subject_token: ci_id_token, tenant: "acme")
# res["access_token"] is an agent-kind token for POST /v1/ai/...
Like KnoxCall.signup this is a module-level function and NOT a method on a constructed client, and for a stronger reason: the whole point is that CI holds no KnoxCall credential. Constructing a client to reach this endpoint would require the very secret the flow exists to remove — so NO Authorization header is sent. The subject_token IS the credential, verified against the issuer's published JWKS.
Pass resource — the resource field of an MCP server's create/get
response — to narrow the minted token to tool kind, confined to exactly
that one /v1/mcp/nil (the default) for an +agent+-kind token: an EMPTY STRING is sent
through and refused invalid_target, because dropping it silently would
mint an UNCONFINED token while the caller believes it is
audience-restricted.
THE HOST MATTERS, and getting it wrong looks like a credential failure.
/v1/oauth/token is part of the DATA plane: src/server.ts hands
/v1/ai/, /v1/mcp/ and /v1/oauth/ to the proxy
router only when the request lands on a tenant data-plane host
({slug}.knoxcall.com, sandbox-{slug}...). Verified against a
running server on 2026-08-25: the same request answers 400 invalid_grant on
acme.knoxcall.com and 401 on api.knoxcall.com - a caller who points this
at the management host reads that 401 as "my CI token was rejected" when the
endpoint is simply not served there. So tenant (or an explicit base_url)
is REQUIRED: there is no safe default to guess.
NOT the tenant OAuth 2.1 token endpoint at
https://api.knoxcall.com/oauth/token (root host, no /v1), which
mints kc_ MANAGEMENT tokens from client_credentials and friends.
Returns the RFC 8693 §2.2.1 body — access_token, issued_token_type,
token_type, expires_in, scope. A BARE OAuth body, not the
meta envelope the rest of /v1 returns.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/knoxcall/token_exchange.rb', line 69 def self.exchange_token(subject_token:, resource: nil, audience: KNOXCALL_AUDIENCE, tenant: nil, sandbox: false, base_url: nil, timeout: 30) origin = exchange_base_url(tenant, sandbox, base_url) # the request carries the workload OIDC id_token, which IS a credential -- the # whole point of the flow. PARITY 15 already warns when a CLIENT is constructed # against plaintext http to a non-loopback host, and this function deliberately # constructs no client, so without this the control exists on one path and is # simply absent on the parallel one. A warning rather than a refusal because the # acceptance harness and local dev legitimately use http://127.0.0.1. if Warnings.insecure_remote_url?(origin) Warnings.warn_once( "KNOXCALL_INSECURE_TRANSPORT", "KnoxCall: exchanging a workload OIDC token over plaintext HTTP to #{origin} - the " \ "subject token is a credential and is readable on the wire. Use https://." ) end uri = URI.parse(origin + "/v1/oauth/token") payload = { "grant_type" => TOKEN_EXCHANGE_GRANT, "subject_token" => subject_token, "subject_token_type" => ID_TOKEN_TYPE, "audience" => audience } payload["resource"] = resource unless resource.nil? req = Net::HTTP::Post.new(uri) req["Content-Type"] = "application/json" req["Accept"] = "application/json" req["User-Agent"] = SDK_VERSION req.body = JSON.generate(payload) resp = begin http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" http.open_timeout = timeout http.read_timeout = timeout http.start { |h| h.request(req) } rescue Net::OpenTimeout, Net::ReadTimeout => e raise ConnectionTimeoutError, "token exchange timed out: #{e.message}" rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e raise NetworkError, "token exchange failed: #{e.class}: #{e.message}" end # A non-JSON error page (a proxy 502) parses to nil and falls through to # the status check rather than masking the status. parsed = begin JSON.parse(resp.body.to_s) rescue JSON::ParserError nil end status = resp.code.to_i token = parsed.is_a?(Hash) && parsed["access_token"].is_a?(String) ? parsed["access_token"] : nil if status >= 400 || token.nil? || token.empty? # AIGW-163: this endpoint is on the TENANT DATA PLANE, so when the AI # gateway has failed to boot it is answered by the plane's 503 sentinel — # the data-plane envelope with code "ai_gateway_unavailable" and a # Retry-After — not by an RFC 6749 error. Typing it means a CI job is told # to wait rather than handed a generic exchange failure. The discriminator # is exact: an RFC 6749 body carries no `code` at all. ai_err = KnoxCall.ai_gateway_error_from(status, parsed, resp) raise ai_err if ai_err code = parsed.is_a?(Hash) && parsed["error"].is_a?(String) ? parsed["error"] : "token_exchange_failed" = if parsed.is_a?(Hash) && parsed["error_description"].is_a?(String) parsed["error_description"] else "Token exchange failed with status #{status}" end raise TokenExchangeError.new(, status_code: status, error_type: code, body: parsed) end parsed end |
.login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) ⇒ KnoxCall::Client
Run the interactive browser (loopback) or device-code login, persist the credential to ~/.knoxcall/credentials.json, and return a ready client bound to the written profile.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/knoxcall/login.rb', line 47 def login(tenant: nil, sandbox: false, base_url: nil, profile: nil, mode: "auto", timeout: 300.0, allow_non_interactive: false, open_browser: nil, client_options: {}) mode = mode.to_s unless %w[auto browser device].include?(mode) raise ArgumentError, %(mode must be "auto", "browser", or "device" (got #{mode.inspect})) end interactive_guard(allow_non_interactive) resolved_base = (base_url || CLI::Common.default_base_url(sandbox)).chomp("/") resolved_profile = CredentialsFile.resolve_profile(profile) use_device = mode == "device" || (mode == "auto" && !desktop_browser?) token_body = if use_device CLI::Login.device_flow(resolved_base) else CLI::Login.auth_code_flow(resolved_base, tenant: tenant, open_browser: open_browser, timeout: timeout) end CLI::Common.persist_login( path: CredentialsFile.resolve_path, profile: resolved_profile, base_url: resolved_base, token_body: token_body, fallback_tenant: tenant ) client_from_profile(resolved_profile, sandbox, ) end |
.signup(input, base_url: nil, timeout: 30) ⇒ Hash
Start creating a KnoxCall account.
Always answers 202 with an opaque claim_handle and emails a sign-in link
— no account, tenant or credential exists until that link is clicked.
Collect the starter kit afterwards with claim_signup. Rate limited to 3
signups/hour/IP.
Enumeration-safe: the reply is identical for an address that already has an account (it receives a sign-in link and a handle that stays "pending").
input fields: email (required), tenant_name (required), full_name,
tenant_slug (omit to have one derived — recommended), country,
region ("us"|"eu"|"au").
97 98 99 |
# File 'lib/knoxcall/signup.rb', line 97 def self.signup(input, base_url: nil, timeout: 30) _signup_post("/v1/signup", input, "Signup", base_url, timeout) end |