49 KiB
Craft External Apps and Egress Proxy
The security core of Craft. Every outbound HTTPS call a Craft sandbox makes is relayed through
sandbox-proxy, amitmproxy-based man-in-the-middle that terminates TLS with its own CA and identifies the sandbox and user. It matches each call against connected external apps and MCP servers. For a matched call it enforces an admin policy (ALWAYS/ASK/DENY) and injects the real credential in place of a sandbox-visible placeholder. It resolves the session only forASKactions. It forwards unmatched traffic upstream unchanged, under the sandbox egress rules.
Verified against: 268e4d5a3d (2026-10-05)
Domain: craft
Edition: CE, with a Cloud-only lockdown on built-in credential editing (see [[craft-admin]])
Owns:
backend/onyx/sandbox_proxy/ (server.py, request_evaluator.py,
credential_injection.py, approval_cache.py, destination_policy.py, sandbox_identity/resolution.py, sandbox_identity/kubernetes.py,
sandbox_identity/docker.py, certificate_authority/bootstrap.py, certificate_authority/kubernetes.py, certificate_authority/docker.py, errors.py,
logging_utils.py, mcp_jsonrpc.py, addons/gate.py, resolvers/external_app.py,
resolvers/mcp_server.py, resolvers/mcp_matching.py, resolvers/onyx_pat.py),
backend/onyx/external_apps/ (credentials.py, token_refresh.py,
matching/engine.py, matching/rules.py, matching/request.py, providers/),
backend/onyx/db/external_app.py, backend/onyx/db/gated_app.py,
backend/onyx/server/features/build/external_apps/ (api.py, models.py,
oauth.py), backend/onyx/server/features/build/approvals/api.py,
backend/onyx/server/features/build/db/action_approval.py
Read first: [[craft-admin]] for the admin surface that configures what
this document enforces, then
docs/craft/features/egress-proxy-and-approvals/README.md for the design
rationale, and docs/craft/features/external-apps/action-policies.md and
oauth-token-refresh.md for the policy and refresh contracts.
1. What the user experiences
A Craft agent can call out to the public internet freely (installing packages, fetching docs) and, once the user has connected an app (Slack, Gmail, Linear, GitHub, a custom API, an MCP server), it can act as that user against that service: read a Slack channel, create a Linear issue, send a Gmail draft.
If the admin has set an action to require approval, the agent's attempt pauses. A card appears in the session ("Craft is requesting approval to create a Linear issue") and the user approves, rejects, or (for some approvals) grants the same action for the rest of the session. If the admin has set an action to always deny, the call fails immediately with a message telling the agent the integration is blocked by policy, not a silent no-op.
The agent never sees the actual Slack token, Gmail OAuth token, or Onyx API key it is using. It is issued placeholders; the real secret is substituted in transit.
2. Surfaces
Proxy process
sandbox-proxy runs python -m onyx.sandbox_proxy.server
(backend/onyx/sandbox_proxy/server.py:main). It is not an HTTP API in the
usual sense; every Craft sandbox's HTTP_PROXY/HTTPS_PROXY points at it, so
its "surface" is every outbound request a sandbox makes. It also exposes
GET /healthz on SANDBOX_PROXY_HEALTHZ_PORT
(server.py:_build_healthz_handler), gated on CA readiness and identity-lookup
sync (server.py:_Readiness).
User-facing HTTP endpoints (outside the proxy)
Member app responses include service_urls: unique literal HTTP(S) origins for display.
The projection strips credentials, paths, queries, and default ports. Built-in
escaped hostname dots and hyphens are decoded. Unescaped regex dots and ambiguous
regex or wildcard hosts are omitted.
Raw upstream patterns, authentication templates, and organization credentials remain admin-only.
| Method | Path | Handler | Notes |
|---|---|---|---|
| GET | /api/build/apps/{id}/oauth/start |
start_external_app_oauth (server/features/build/external_apps/oauth.py) |
Begins a user's per-user OAuth connect flow for a built-in app. |
| POST | /api/build/apps/oauth/callback |
handle_external_app_oauth_callback (oauth.py) |
Exchanges the authorization code, stamps expires_at, stores per-user credentials. |
| GET | /api/build/approvals/sessions/{session_id}/live |
list_live_approvals (server/features/build/approvals/api.py) |
Polled by the frontend to render pending approval cards. |
| POST | /api/build/approvals/{approval_id}/decision |
submit_decision (approvals/api.py) |
User approves or rejects a parked request. |
| POST | /api/build/approvals/{approval_id}/session-grant |
submit_session_grant (approvals/api.py) |
Approves and remembers the grant for the rest of the session (see §4.5). |
Admin CRUD for ExternalApp (/api/build/admin/apps*) is owned by
[[craft-admin]]; this document owns what those settings mean at request
time.
Environment / configuration
| Variable | Where | Effect |
|---|---|---|
SANDBOX_PROXY_LISTEN_PORT, SANDBOX_PROXY_HEALTHZ_PORT |
server/features/build/configs.py |
Proxy listen and health ports. |
SANDBOX_PROXY_LISTEN_HOST |
configs.py |
Proxy and health listener; defaults to 0.0.0.0. Helm sandboxProxy.listenHost: "::" enables IPv6 listeners. |
SANDBOX_PROXY_ALLOW_GLOBAL_CLIENTS |
configs.py |
Defaults to false. Enable through sandboxProxy.allowGlobalClients for global IPv6 pod addresses, with restricted proxy ingress. Known sandbox identity remains required. |
SANDBOX_PROXY_INTERNAL_CIDRS |
configs.py |
Comma-separated internal ranges, including global IPv6 VPC, pod, Service, node, and connected-network ranges. The proxy denies destinations in these ranges. |
SANDBOX_PROXY_SSL_VERIFY_UPSTREAM_TRUSTED_CA |
configs.py |
mitmproxy upstream cert verification mode. |
SANDBOX_BACKEND (SandboxBackend.KUBERNETES/DOCKER) |
configs.py |
Selects K8sSecretCAStore/K8sInformerLookup vs. FileCAStore/DockerEventsLookup (sandbox_proxy/certificate_authority/bootstrap.py:build_ca_store, sandbox_proxy/sandbox_identity/resolution.py:build_ip_lookup). |
SANDBOX_PROXY_CA_SECRET, SANDBOX_PROXY_CA_CONFIGMAP, SANDBOX_PROXY_NAMESPACE |
configs.py |
K8s CA persistence and cross-namespace projection targets (certificate_authority/kubernetes.py). |
SANDBOX_PROXY_CA_VOLUME_PATH |
configs.py |
Docker CA persistence volume (certificate_authority/docker.py). |
ONYX_SERVER_URL |
configs.py |
The configured internal API host the proxy allows through the destination-block, and the host the OnyxPatResolver claims (gate.py:_parse_api_server, resolvers/onyx_pat.py). |
SANDBOX_APPROVAL_WAIT_TIMEOUT_SECONDS |
configs.py |
How long the proxy parks a request awaiting an ASK decision before claiming EXPIRED (gate.py:_await_decision). |
MCP_SESSION_TAG_HEADER |
configs.py |
Header opencode's in-process MCP client uses to carry the session tag (gate.py:_extract_session_tag). |
PARSER_MAX_BODY_BYTES |
sandbox_proxy/addons/gate.py (constant, not env) |
32 MiB request-body cap; see §9. |
AUTO_PROVISION_DEFAULT_EXTERNAL_APPS |
backend/onyx/configs/app_configs.py (default false) |
Seeds Onyx-managed built-ins (disabled) on tenant creation. |
The proxy requires SANDBOX_PROXY_INTERNAL_CIDRS at startup when the listener
uses IPv6 or global clients are enabled. Invalid CIDRs also prevent startup.
Internal destinations remain blocked for HTTP and CONNECT. The exact
ONYX_SERVER_URL host and port remain an internal destination exception.
Craft-enabled MCP servers also permit their exact scheme, host, and port for
users with access in the sandbox tenant. The resolver caches access for 30 seconds.
Lookup failures deny access. The tenant’s SSRF Protection setting applies to the addresses used for the connection.
This exception uses get_security_settings(allow_fallback=False); failed reads cannot substitute environment defaults.
Raw TCP forwarding is disabled. Path, credential, and action checks still apply.
MCP requests cannot upgrade to WebSocket or other protocols that bypass HTTP action checks.
Kubernetes identity lookup indexes each pod's primary status.pod_ip.
Use listeners and Services in that address family: IPv4 remains the default;
IPv6 listeners support IPv6-only deployments. Switching an IPv4-primary
dual-stack deployment to secondary IPv6 pod addresses is not supported.
Helm sandboxProxy.egressAllowIPv6 enables public IPv6 egress in the proxy
NetworkPolicy. It does not change the listener. The chart requires
sandboxProxy.internalCIDRs when IPv6 egress, global clients, or an IPv6
listener is enabled.
3. Data model
ExternalApp ----------------------< ExternalAppUserCredential (per user, per app)
| organization_credentials (encrypted) user_credentials (encrypted)
| auth_template (header -> "{placeholder}" template)
| upstream_url_patterns (glob for CUSTOM, regex for built-in)
|
+--< ExternalApp__Skill >--- Skill
GatedApp (kind, target_id) -- one row per external_app OR mcp_server
| external_app_id XOR mcp_server_id (ck_gated_app_single_target)
+--< GatedActionPolicy (action_id -> ALWAYS|ASK|DENY, sparse)
ActionApproval -- one row per gated request the proxy parked or auto-decided
session_id -> BuildSession
actions: JSONB list[MatchedAction-shaped dict], strictest-first
decision: NULL (pending) | APPROVED | REJECTED | EXPIRED
decided_via: USER | PRE_APPROVAL | SESSION_GRANT (NULL = legacy/proxy-claimed)
ExternalApp(backend/onyx/db/models.py:ExternalApp): the configured app.upstream_url_patternsis a plain regex list for built-ins and a glob list forCUSTOM(translated viaExternalApp.upstream_url_regexes). They come from the client for fixed-host built-ins and custom apps. Per-account providers resolve their own patterns after the database write path restores masked credentials. Zendesk uses the same validated subdomain for its gateway, OAuth endpoints, and skill files.organization_credentialsand (onExternalAppUserCredential)user_credentialsareEncryptedJson/SensitiveValue, read only via.get_value(apply_mask=False).ExternalAppUserCredential(models.py): one row per(external_app_id, user_id).granted_scopesisNULLwhen the provider didn't report a scope grant, a list when it did; the two must not be conflated.GatedApp(models.py): the shared identity row for anything policy-gated.CheckConstraint ck_gated_app_single_targetenforces exactly one ofexternal_app_id/mcp_server_id. Rows are created lazily (onyx/db/gated_app.py:get_or_create_gated_app_id); a target with no row has never been policied or approved.GatedActionPolicy(models.py): sparse per-action override, one row per(gated_app_id, action_id). An unset action resolves to the catalog'sdefault_policyat read time (external_apps/providers/registry.py:effective_policy), not a stored row with a default value.ActionApproval(models.py): one row per gated request, whether parked for a human or auto-decided by a grant.decision IS NULLmeans pending or a proxy-crash orphan;actions[0](JSONB, insertion-sorted strictest-first) is what governed the request (server/features/build/db/action_approval.py:insert_action_approval).Sandbox.encrypted_pat(models.py): the sandbox's own scoped Onyx API token, decrypted only insideOnyxPatResolver.resolve(sandbox_proxy/resolvers/onyx_pat.py).BuildSession(models.py): the session anASKrequest is attributed to; see[[craft-sessions]].
4. How it works
4.1 The life of one outbound request
sandbox process (opencode / a tool call)
│ HTTP_PROXY / HTTPS_PROXY points at sandbox-proxy
▼
1. TLS termination (MITM)
mitmproxy decrypts the CONNECT tunnel using the proxy's own CA
(certificate_authority/bootstrap.py, certificate_authority/kubernetes.py / certificate_authority/docker.py), which the sandbox was made to trust
at boot (firewall-init.sh). GateAddon.http_connect also captures the
in-band session tag off Proxy-Authorization here: it is only visible
on the CONNECT, not the decrypted inner request.
sandbox_proxy/addons/gate.py:GateAddon.http_connect
│
2. Destination checks and address pinning
resolve_destination applies one policy across all three hooks.
For new connections, it resolves DNS once. For reused connections, it checks
their addresses. Public addresses and ONYX_SERVER_URL's exact host:port pass.
Other addresses require an accessible Craft MCP origin.
CONNECT checks HTTPS; requests check their scheme. Upstream sockets check TLS
or the accepted CONNECT origin before allowing internal MCP addresses.
For internal addresses, resolve_destination identifies the source sandbox,
checks exact MCP origin access, and reads tenant SSRF settings without fallback.
resolve_destination applies these settings to the original DNS answers,
including embedded IPv4, with the shared URL validator.
Public and Onyx destinations do not require MCP or tenant-settings reads.
Only these validated addresses are pinned.
Raw TCP forwarding is disabled, so CONNECT payloads must pass HTTP checks.
http_connect and request check destinations before processing traffic.
The gate records approved addresses for pending upstream connections, then
replaces them with the actual peer address when connected. Each request checks
matching pool candidates because mitmproxy selects the connection after request().
Disconnects and connection failures remove these records. This stores connection
facts, not access decisions, so policy changes apply to reused sockets.
server_connect resolves and validates all answers again, then pins the
upstream TCP connection to those addresses through UpstreamEventLoop.
No new hostname lookup occurs between validation and connection.
The original hostname remains in server.address and TLS SNI, preserving
hostname verification, MITM processing, and credential injection.
sandbox_proxy/destination_policy.py:resolve_destination, pin_destination,
UpstreamEventLoop; sandbox_proxy/addons/gate.py:GateAddon.server_connect
│
3. Identity resolution
GateAddon._resolve_and_match extracts the client's source IP and calls
IdentityResolver.resolve_sandbox(src_ip): backend-specific IP->identity
lookup (K8sInformerLookup watches sandbox pods; DockerEventsLookup
streams container events), then a DB read of Sandbox.user_id. Unknown IP
-> 403 unidentified_sandbox (fail closed).
sandbox_proxy/sandbox_identity/resolution.py:IdentityResolver.resolve_sandbox
│
4. Body-size gate
flow.request.raw_content is None (streamed body) or > 32 MiB
(PARSER_MAX_BODY_BYTES) -> 403 body_too_large before the matcher ever
parses it.
sandbox_proxy/addons/gate.py:PARSER_MAX_BODY_BYTES
│
5. App / MCP matching
CompositeRequestEvaluator tries ExternalAppRequestEvaluator then
McpRequestEvaluator, first non-None verdict wins. The external-app
evaluator matches request.url against every enabled app's
upstream_url_patterns (resolve_app_for_url), then classifies the request
into catalog action(s) via recognize_actions + apply_credential_gate.
The MCP evaluator attributes by host + longest-path-prefix
(resolvers/mcp_matching.py:match_request) among the user's accessible
craft-enabled MCP servers, then parses the JSON-RPC body
(mcp_jsonrpc.py:classify_mcp_request) to find the invoked tool name(s).
A matcher exception, or no app/server owning the URL at all, yields
matched_actions = None ("off-catalog").
sandbox_proxy/request_evaluator.py:CompositeRequestEvaluator.evaluate
│
6. Policy evaluation
matched_actions is None -> off-catalog: skip policy evaluation (ungated),
but continue to step 7 for host-based credential
injection or a credential-error block.
governing_action.policy DENY -> 403 policy_denied, no DB row, request not forwarded.
governing_action.policy ALWAYS -> proceed straight to credential injection (step 7).
governing_action.policy ASK -> resolve the originating BuildSession from the
in-band session tag (fail closed if absent/
unverifiable); check for a reusable grant
(scheduled-task pre-approval, or a live
session grant); if none, persist an
ActionApproval row, announce it, and park
on a wake channel until decided or expired.
sandbox_proxy/addons/gate.py:GateAddon._resolve_and_match, ._await_decision
│
7. Credential injection
CredentialInjectionDispatcher walks OnyxPatResolver, MCPServerResolver,
ExternalAppResolver in order (first host-claim wins). The claiming
resolver renders real auth headers (refreshing an expiring OAuth token
first, if needed) and the dispatcher writes them onto flow.request.
A claim with no headers still counts as CLAIMED (forward as-is); a
resolver that cannot produce a credential raises
CredentialUnavailableError -> 403 credential_error.
sandbox_proxy/credential_injection.py:CredentialInjectionDispatcher.apply
│
8. Upstream call
mitmproxy forwards the (possibly header-rewritten) request to the real
host. The Proxy-Authorization and MCP session-tag headers are stripped
first so they never reach the origin.
sandbox_proxy/addons/gate.py:GateAddon.request
4.2 The CA: what it is, what it can and cannot see
The proxy generates a self-signed CA (RSA-4096, 5-year validity,
x509.BasicConstraints(ca=True, path_length=0),
sandbox_proxy/certificate_authority/bootstrap.py:CABootstrap._generate_ca) once per deployment and
persists it: a Kubernetes Secret in the proxy's own namespace holding
ca.crt/ca.key, with only the public cert mirrored into a ConfigMap in
the sandbox namespace for cross-namespace mounting
(sandbox_proxy/certificate_authority/kubernetes.py:K8sSecretCAStore); or a shared Docker Compose
volume where ca.key is 0600 root-owned and ca.crt is world-readable
(sandbox_proxy/certificate_authority/docker.py:FileCAStore). Multiple proxy replicas cold-start
safely by racing on Secret/file creation; the loser reloads the winner's CA
(CAStoreConflictError).
The sandbox trusts this CA at boot
(docs/craft/features/egress-proxy-and-approvals/README.md: firewall-init.sh
installs it into the OS trust store and several SDK-specific CA env vars:
NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE,
AWS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO). This is what lets
mitmproxy MITM every HTTPS connection the sandbox makes: it terminates the
sandbox's TLS with a certificate it mints on the fly, signed by this CA, and
opens its own separate TLS connection to the real upstream.
What this means (unsoftened): the proxy sees every HTTPS request and
response body in plaintext from every Craft sandbox in the deployment, for
any host the sandbox talks to, not only connected apps. This is a private CA
scoped only to intercepting a sandbox's own outbound traffic; it is not
usable to impersonate a public site to anyone outside that sandbox's proxy
path, and the private key never leaves the CA store described above. I could
not verify, from this code, whether the plaintext bodies the proxy decrypts
are logged, persisted, or forwarded anywhere beyond the immediate
request/response cycle; logging_utils.py logs only metadata (host, method,
app name, action type, policy, credential outcome), never bodies or header
values, but that does not rule out other observability paths outside this
component's code.
4.3 Identity: is it spoofable from inside the sandbox
Base identity (sandbox_id, user_id, tenant_id) is resolved purely from
the TCP source IP of the connection into the proxy
(sandbox_proxy/sandbox_identity/resolution.py:IdentityResolver.resolve_sandbox), against a
cache the proxy itself builds by watching sandbox pods/containers
(sandbox_identity/kubernetes.py:K8sInformerLookup, sandbox_identity/docker.py:DockerEventsLookup).
Code inside the sandbox cannot change its own source IP as seen by the proxy,
so this layer is not spoofable from inside a sandbox in either backend.
This assumes the deployment topology puts one sandbox per pod/container
with a stable, uniquely-labelled IP, and that nothing NATs multiple
sandboxes behind one IP the proxy sees. Both lookups fail loud on a
duplicate IP mapping to two different sandbox_ids at initial sync
(sandbox_identity/kubernetes.py:_initial_list, sandbox_identity/docker.py:_initial_sync),
which is the code's own acknowledgment of that assumption.
Session-level identity (which BuildSession an ASK request belongs to) is
different: it rides an in-band tag (the BuildSession id, sent as the
Proxy-Authorization basic-auth username by an opencode plugin,
session-proxy-tag.ts) or, for opencode's in-process MCP client, a separate
header (MCP_SESSION_TAG_HEADER). Both are forgeable by code running
inside the sandbox (gate.py:_extract_session_tag's own comment says so
explicitly). The proxy bounds the damage, not eliminates it:
resolve_session_by_id verifies the tagged session belongs to the
IP-resolved user_id
(sandbox_identity/resolution.py:IdentityResolver.resolve_session_by_id), so a forged tag can
only misattribute an approval to a different session of the same user,
never to another user or tenant. A tag that doesn't resolve at all fails
closed (session_missing/session_malformed/session_unverified ->
no_active_session, no guessed fallback).
4.4 Credential injection: placeholder in, real secret out
Sandboxes are issued placeholders, never real secrets
(ONYX_PAT=replaced_by_egress_proxy, an opencode LLM api_key placeholder,
GH_TOKEN=replaced_by_egress_proxy, per
docs/craft/features/egress-proxy-and-approvals/README.md). The three
resolvers registered in sandbox_proxy/server.py:build_resolvers each claim
requests by host, not by the placeholder value:
OnyxPatResolver(resolvers/onyx_pat.py): claims requests to theONYX_SERVER_URLhost+port, decryptsSandbox.encrypted_pat, and injects it as bothAPI_KEY_HEADER_NAMEand its alternative name. See[[auth-and-identity]]for what a sandbox PAT is scoped to do.MCPServerResolver(resolvers/mcp_server.py): claims a craft-enabled MCP server's host (+ path prefix), resolves the sandbox owner's stored MCP credentials, refreshing an expired OAuth token first if needed (refresh_mcp_oauth_token_if_expired).ExternalAppResolver(resolvers/external_app.py): claims a request the matcher has already attributed to a connectedExternalApp, refreshes an expiring OAuth token (ensure_fresh_credentials), then renders the app'sauth_templateagainst the merged org + per-user credentials (external_apps/credentials.py:resolve_injection_headers).
Verified: the agent's process never receives the rendered header value.
build_auth_headers (external_apps/credentials.py) substitutes
{placeholder} fields via str.format, which "does not re-interpret braces
inside the substituted values" (the function's own docstring) and the
result is written directly onto the outbound flow.request.headers
(credential_injection.py:CredentialInjectionDispatcher.apply) inside the
proxy process, never echoed back to the sandbox in any response body or
header the code constructs.
What happens if the agent tries to read the credential directly: I could
not find a code path that would let it. The sandbox process only ever holds
the placeholder string; the real value exists only inside the proxy
process's memory and the encrypted DB columns
(ExternalApp.organization_credentials, ExternalAppUserCredential.user_credentials,
Sandbox.encrypted_pat, all EncryptedJson/SensitiveValue). A sandbox
attempting to curl the credential-issuing DB or the proxy's own control
plane directly, rather than through a matched app request, would be a
request to an internal address and blocked by resolve_destination
(§4.1 step 2) before it ever reached anything that could answer.
4.5 Approvals
For an ASK-policied action with no applicable grant,
GateAddon._persist_approval_row inserts a pending ActionApproval row,
adds it to an in-memory ParkedApprovals set, and best-effort RPUSHes its
id onto approval:announce:{session_id} (approval_cache.py:announce_approval)
so the chat-stream merger can surface the card on the live SSE stream; a miss
just falls back to the frontend's next /live poll
(GET /api/build/approvals/sessions/{session_id}/live). The proxy then
BLPOPs approval:wake:{approval_id} for up to
SANDBOX_APPROVAL_WAIT_TIMEOUT_SECONDS
(approval_cache.py:wait_for_wake); the user's decision
(POST /api/build/approvals/{approval_id}/decision) writes the DB row via
try_record_decision (the sole race arbiter,
server/features/build/db/action_approval.py:try_record_decision) and
RPUSHes the wake key. A timeout with no wake claims EXPIRED
conditionally, falling back to whatever decision actually won the DB race
(gate.py:_claim_expired_or_read_winner). See [[craft-sessions]] for how
the session and its live stream are structured; see
[[observability]] for notification delivery
(_notify_approval_requested).
Two grant sources let an ASK request bypass the human prompt entirely:
- Scheduled-task pre-approval (
_scheduled_task_grant): aRUNNINGscheduled run whose task configuration pre-approved this(kind, target_id). - Session grant (
_session_grant): the user previously chose "approve for this session" viasubmit_session_grant; cached per-action in Redis (approval_cache.cached_session_grants_cover) with the persistedActionApprovalrows as the durable source of truth.
Either grant source still writes an ActionApproval row (pre-decided
APPROVED, tagged with the originating decided_via), so the audit trail is
identical in shape whether a human or a grant decided.
4.6 OAuth connect and token refresh
OAuth start accepts an optional safe internal return_path. The authorization attempt
stores it and the callback returns it after saving credentials. Omitting the path
retains the existing callback behavior. External and unsafe paths are rejected.
Connect: GET /api/build/apps/{id}/oauth/start builds the provider's
authorize URL through the shared OAuth builder (server/features/build/external_apps/oauth.py).
Each attempt is bound to the user, tenant, app, and OAuth configuration.
AuthorizationAttemptStore keeps attempts for ten minutes through CacheBackend.
GitHub, Linear, and Zendesk use S256 PKCE. Other providers keep their existing exchange format.
The callback consumes the attempt before token exchange and rejects changed configuration.
A failed exchange requires a new connection attempt; retries cannot reuse state.
The callback rejects malformed provider data and non-string access tokens before storage.
Providers own scope parameters, client authentication, and token and grant extraction.
The callback stamps an absolute expires_at
(external_apps/token_utils.py:stamp_expires_at), stores the granted scopes, and refreshes the user's sandbox skills.
Zendesk uses a Confidential OAuth client with S256 PKCE and a client secret.
It requests read, tickets:write, hc:write,
side_conversations:write, and macros:write scopes. All five are requested.
Help Center, side conversations, and macros have separate read and write action policies.
Reads default to ALWAYS; writes default to ASK, including nested and bulk resource routes.
The skill uses its existing call command for these workflows. Articles start as drafts;
macro examples use a personal restriction. Zendesk roles and feature availability still apply.
View and trigger write scopes are not requested.
Search and ticket audits require broad read; resource-specific read scopes do not cover these endpoints.
Existing users must reconnect after allowed scopes change to obtain the new grant.
The resolved OAuthFlowSpec comes from
OAuthExternalAppProvider.resolve_oauth_flow, not straight from the
spec: a provider whose endpoints live on the customer's own host (Zendesk's
<subdomain>.zendesk.com) resolves them from the app's organization
credentials. Both OAuth routes and the refresh path resolve them the same
way. The attempt fingerprint includes these resolved endpoints, so an account
change rejects pending callbacks before any token exchange. The same resolved
configuration supplies URLs, scopes, and PKCE settings. Refresh requires the
resolved token URL; it cannot fall back to an unresolved spec template.
OAuthExternalAppProvider.extract_credentials maps standard OAuth token responses for GitHub and Zendesk.
Providers with different fields or nested tokens override this mapping.
Absent refresh fields remain omitted, so refresh merging preserves stored values.
Refresh is lazy and happens at the credential-injection seam, not on a
schedule: ExternalAppResolver.resolve calls ensure_fresh_credentials
(external_apps/token_refresh.py) before rendering headers. It is a fast
no-op when the token isn't stale; when it is, it acquires a
fleet-wide Redis lock (ea_token_refresh:{tenant}:{app}:{user}), re-checks
staleness under the lock, POSTs the refresh off any DB connection, and
persists the result. A terminal refresh failure (revoked grant) clears
the stored credential rather than raising, so the app reads as disconnected
on the next request rather than the proxy silently forwarding a dead token.
See docs/craft/features/external-apps/oauth-token-refresh.md for the full
design rationale (single-flighting, why lazy over background).
4.7 Cloud-managed vs. self-hosted credentials
On Onyx Cloud, Onyx owns the OAuth client credentials for the built-in
providers (OnyxManagedExtApp subclasses,
docs/craft/features/external-apps/cloud-managed-app-credentials.md);
tenants are seeded with these apps disabled at tenant creation
(AUTO_PROVISION_DEFAULT_EXTERNAL_APPS). An admin on Cloud can only toggle
enablement and edit action policies for these; credentials, auth_template,
and upstream_url_patterns are blanked in the admin API response and never
sent to the client. Self-hosted admins configure their own OAuth app
credentials, unchanged. Per-user OAuth (each user's own token) and injection
are identical in both modes; only who owns the client credential differs.
4.8 MCP through the proxy
MCP traffic is JSON-RPC over HTTP, so every tool invocation looks like
POST to the same URL; matching a URL alone cannot distinguish "call tool A"
from "call tool B". mcp_jsonrpc.py:classify_mcp_request parses the body (a
single message or a batched array) and classifies it as PLUMBING
(handshake, tools/list, resource reads, an explicit allowlist plus
notifications/*, forwarded ungated with credentials injected),
TOOL_CALL (one MatchedAction per invoked tools/call message, gated at
its per-tool policy, default ASK), or UNCLASSIFIABLE (anything that
doesn't parse cleanly as one of the above). An UNCLASSIFIABLE body on a
matched MCP host produces a single synthetic DENY action
(request_evaluator.py:MCP_UNCLASSIFIABLE_ACTION_TYPE) so the gate fails
closed instead of forwarding with injected credentials. The credential
resolver (resolvers/mcp_server.py:MCPServerResolver.resolve) independently
re-parses the body and refuses to inject onto anything but PLUMBING unless
the gate has already produced a verdict for it, so a matcher bug cannot
result in a tool call forwarding with credentials but no policy check.
Implication for reliability: MCP gating is coupled to JSON-RPC body
parsing, not just URL routing. A client library that changes framing (batches
differently, adds an unrecognized top-level field, streams the body so
raw_content is None) can turn a previously-gated tool call into a denied
one, or trip the 32 MiB request-body cap on a legitimately large tool call
payload.
Both evaluator and resolver import the same pure matching primitives
(resolvers/mcp_matching.py:match_request, parse_target) specifically so
they can never disagree about which server owns a request; see
[[mcp-and-custom-tools]] for the MCP server data model and connection UI.
5. Contracts and invariants
- The agent must never be able to read an injected credential. The
dispatcher writes rendered headers directly onto the outbound request
inside the proxy process (
credential_injection.py:apply); nothing echoes a header value back into a response body or log (logging_utils.pylogs header names, never values,credential_injected header_names=%s). I could not verify this against every possible upstream response shape (e.g. an API that reflects request headers back in its response body is a property of the upstream, not this proxy, and would leak the header regardless of this contract). - A
DENYverdict blocks forwarding of the request. The upstream TLS connection may already be open, because mitmproxy's defaulteagerstrategy connects beforerequest()sees the decrypted request._resolve_and_matchsets the 403 and returns beforerequest()ever reaches credential injection or forwarding (gate.py:GateAddon._resolve_and_match); noActionApprovalrow is written for aDENY. - An app's
upstream_url_patternspin a literal upstream host. The host is the credential-injection boundary: a pattern spanning a whole domain would let a prompt-injected agent spend this tenant's token on another account of the same vendor.UrlGlobenforces this forCUSTOMapps (the host may hold no wildcard). Fixed-host built-ins use submitted regexes. Per-account providers resolve their patterns on create and config updates, after masked credentials are restored. Zendesk validates one DNS label and escapes it before substitution. Invalid account values reject the write instead of saving a gateway that cannot inject credentials. - Identity is not spoofable for the sandbox/user/tenant triple; it is
spoofable for which session within that user gets attributed. See §4.3.
Any change to session-tag handling must preserve the "verified against the
IP-resolved user" check in
resolve_session_by_id; removing it would let a compromised sandbox process attribute an approval to any session, including a different user's if that check is what enforces the boundary. - The CA is scoped to the sandbox and must never leave it. The private
key lives only in the CA store (
Secret/volume) and the proxy process; only the public cert is projected to sandboxes. A change that starts mountingca.key(or the K8sSecretitself) into the sandbox namespace breaks this. - An unmatched request's default depends on what went unmatched, and
the two cases differ:
- A request that matches no connected app and no MCP host at all
(
resolve_app_for_urlfinds nothing, and no MCP server host claims it) is not evaluated by this gate at all (matched_actions is Noneinrequest_evaluator.py). It is governed only by the sandbox's own iptables egress lockdown (firewall-init.sh), which allows general outbound HTTPS. See[[craft-admin]]§5.4 for why this is intentional, not a leak. - A request to a configured MCP host whose path is outside every
server_urlprefix is not pass-through.MCPServerResolver.claimsstill claims the host, andMCPServerResolver.resolveblocks the request withCredentialUnavailableError(credential_error). - A request that matches a connected, available app but no specific
catalog action defaults to a synthesized whole-domain
ASK(WHOLE_DOMAIN_ACTION_TYPE = "unspecified",matching/engine.py:apply_credential_gate), notALWAYS. An app whose credentials are currently unavailable forwards bare (no prompt) unless a matched action is explicitlyDENY, in which case theDENYstill applies without credentials. - An MCP tool call with no admin override defaults to
ASK(request_evaluator.py:MCP_TOOL_DEFAULT_POLICY).
- A request that matches no connected app and no MCP host at all
(
- MCP matching must fail closed when the body cannot be parsed. Any
non-plumbing, non-well-formed-
tools/callbody on a matched MCP host isUNCLASSIFIABLEand becomes a syntheticDENY(request_evaluator.py:_mcp_tool_actions), never a silent pass-through. - The upstream socket must use only validated destination addresses.
http_connectandrequestreject forbidden destinations early. Requests recheck pending and connected upstream addresses under current policy.server_connectcallsresolve_destinationand rejects the entire answer set if any address is forbidden.pin_destinationandUpstreamEventLoopuse those approved addresses for the TCP connection without another DNS lookup. Preserve the original hostname for TLS SNI, certificate verification, and credential matching. Keep the real TLS tests when changing mitmproxy; they verify address pinning, hostname checks, and credential injection. try_record_decisionis the only writer of a terminal decision; any new path that can end an approval (a new UI action, a new grant source) must go through it, not writeActionApproval.decisiondirectly, or the proxy's wake/expire race arbiter breaks.- External-app matcher exceptions are fail-open to "off-catalog", by
design; credential resolution and destination blocking are the actual
security boundary, not the matcher. A bug in
recognize_actionsmust not become a way to reach an internal address or bypass the CA/identity layers; it only affects whether a request looks like an app action versus general internet traffic. MCP is different. AfterMcpRequestEvaluatorattributes a request to a server, an exception fromclassify_mcp_requestbecomes a syntheticDENY(fail closed).
6. Relationships
Depends on
[[craft-admin]]: ownsExternalApp/GatedAppadmin CRUD, the catalogdefault_policyandGatedActionPolicyvalues this document reads at request time, and the network-lockdown-vs-action-gate distinction this document's §5 restates precisely for the proxy's own enforcement code.[[craft-sandboxes]]: the sandbox pod/container this proxy identifies by source IP and whose egress it exclusively mediates;firewall-init.shand the CA-trust bootstrap live there.[[craft-sessions]]:BuildSessionis the unit anASKapproval is attributed to and the live stream an approval card is announced on.[[auth-and-identity]]:Sandbox.encrypted_patis a scoped Onyx API token whose issuance and permission scope this document does not own;OnyxPatResolveronly injects it.[[mcp-and-custom-tools]]:MCPServerconnection config, credentials, and the "available in craft" flag this document's MCP resolver and evaluator both read.[[access-control]]:Permission.FULL_ADMIN_PANEL_ACCESSgates every admin mutation to the tables this document enforces against;Permission.BASIC_ACCESSgates the user-facing OAuth/approval endpoints.[[multi-tenancy]]: every DB read/write here opens a tenant-scoped session (get_session_with_tenant); the Redis cache factory is also tenant-keyed.
Depended on by
- Every Craft sandbox session that connects an external app or MCP server, or calls back into the Onyx API.
[[observability]]:create_notificationcalls forAPPROVAL_REQUESTEDand pre-approved/session-granted actions.
7. Blast radius
| If your change... | Also check |
|---|---|
| adds a new external app (built-in or custom) | resolve_app_for_url's regex/glob correctness for the new upstream_url_patterns (a bad regex is silently skipped, per [[craft-admin]]); the app's catalog (external_apps/providers/) and default per-action policies; whether the app needs a skill association |
changes URL/MCP matching (resolve_app_for_url, mcp_matching.py:match_request, recognize_actions) |
both ExternalAppRequestEvaluator and any credential resolver that independently re-derives attribution (MCPServerResolver.resolve re-parses instead of trusting the gate's cache, deliberately); the ambiguous-match fail-closed paths (AmbiguousMCPTargetError) |
changes the default for unmatched requests (whole-domain ASK, MCP default ASK, or the off-catalog pass-through) |
§5 item 5 of this document; [[craft-admin]]'s "general internet egress is not admin-approved" invariant; every existing connected app's behavior for actions outside its catalog |
changes identity resolution (sandbox_identity/resolution.py, sandbox_identity/kubernetes.py, sandbox_identity/docker.py) |
the duplicate-IP fail-loud check at initial sync; /healthz readiness semantics; the session-tag verification in resolve_session_by_id (§4.3); both SandboxIPLookup backends must stay behavior-identical |
changes credential injection (credential_injection.py, any resolver) |
the first-claim-wins resolver order in server.py:build_resolvers; whether a claim with no headers (CLAIMED) vs. a hard failure (BLOCKED) is still correct for the new resolver; logging_utils.py's "header names only, never values" invariant |
changes the CA bootstrap or persistence (certificate_authority/bootstrap.py, certificate_authority/kubernetes.py, certificate_authority/docker.py) |
every running sandbox's trust store (a rotated CA without a coordinated sandbox restart breaks every in-flight TLS interception); the K8s cross-namespace ConfigMap projection; the half-written-state fail-loud recovery path |
changes the approval flow (gate.py's park/wake, approval_cache.py, action_approval.py) |
[[craft-sessions]]'s live-stream announce path; the SIGTERM drain (GateAddon.drain_inflight) that must terminalize every parked approval before the proxy pod exits; the frontend's /live polling fallback |
changes the request body cap (PARSER_MAX_BODY_BYTES) |
§9's body-limit footgun; whether the new limit still matches or exceeds the relevant upstream's own limit, so the proxy is never the more restrictive failure |
changes GatedApp/GatedActionPolicy |
both consumers: external_apps/api.py (admin writes) and this document's live enforcement (request_evaluator.py, addons/gate.py); MCP servers share this table with external apps |
8. How to verify a change
Tests
# Proxy unit tests (mock DB/cache/mitmproxy internals)
cd backend && uv run pytest tests/unit/sandbox_proxy
# External dependency unit tests (real Postgres/Redis, direct function calls)
cd backend && uv run pytest tests/external_dependency_unit/sandbox_proxy
# External-app matching engine and policy resolution
cd backend && uv run pytest tests/unit/external_apps/matching/test_engine.py
# Admin + policy + credential integration tests
cd backend && uv run pytest tests/integration/tests/external_apps/test_external_app_action_policies.py
cd backend && uv run pytest tests/integration/tests/external_apps/test_external_apps.py
# Docker end-to-end: a real sandbox + real proxy container
cd backend && uv run pytest tests/integration/tests/craft/docker_e2e/test_approval_gate_docker.py
cd backend && uv run pytest tests/integration/tests/craft/docker_e2e/test_sandbox_network_posture_docker.py
backend/tests/external_dependency_unit/sandbox_proxy/test_gate_claim_arbiter.py
is the specific test for try_record_decision's race behavior; do not modify
that arbiter without extending it.
Manual reproduction
- Confirm services are up:
tail -f backend/log/api_server_debug.log, and the sandbox-proxy container/pod logs (sandbox_proxystructured log lines:egress_allow,egress_block,approval_requested,approval_decided). - Connect an external app (e.g. Slack) as
admin_user@example.com/TestPassword123!athttp://localhost:3000, then start a Craft session and have the agent attempt a Slack action. - To confirm a
DENYactually blocks: on/admin/craft/apps, set one Slack action toDENY, have the agent attempt exactly that action, and confirm the proxy log showsegress_block ... reason=policy_deniedwith noegress_allowlog line (the upstream connection itself may still open), and the agent's tool call receives thepolicy_denied403 body verbatim (not a generic failure). - To confirm an
ASKaction pauses correctly: set an action toASK, have the agent attempt it, confirm an approval card appears in the session, approve it, and confirm the proxy'sapproval_decidedlog showsdecision=APPROVED wake=received. - To confirm the credential is never sandbox-visible: with a connected app,
have the agent try to
curlthe app's real API directly with a bogus header and confirm it gets the upstream's auth failure, not a value the agent supplied; there is no code path by which the agent's own request can carry the injected header value forward into anything it can read.
What "working" looks like
- A
DENY-policied action is never forwarded to the upstream host (noegress_allowlog line for it), and the sandbox-visible error ispolicy_denied, not a generic timeout or connection error. - An
ASKaction either surfaces exactly one approval card per distinct gated request, or resolves immediately against a valid grant, never both. - Every
egress_allow/egress_blocklog line carriestenant,sandbox,host, and (when gated)app_name/action_type/policy, sufficient to reconstruct the decision without reading request bodies.
9. Footguns
- The 32 MiB body cap has already caused a real incident. A body-size
limit in the egress proxy once caused Craft turns to die after image reads
(
PARSER_MAX_BODY_BYTES = 32 * 1024 * 1024, deliberately set to match Anthropic's own Messages API limit so the proxy is never the more restrictive party for a normal LLM call, pergate.py's own comment). Any future change to this cap, or the addition of a new large request flow (file uploads, big tool-call payloads), must re-check it against the relevant upstream's own limit, not just this proxy's number in isolation. - General internet reachability from a sandbox is intentional, not a gap
this proxy should close. Only requests attributed to a connected
app/MCP server are policy-gated; everything else (except requests to a
configured MCP host, which
MCPServerResolverstill claims) is governed by the sandbox's iptables lockdown (all outbound traffic dropped except to the proxy, which forwards unmatched public traffic). Do not conflate "the sandbox can reachexample.com" with "policy was bypassed." See[[craft-admin]]§5.4 and §5 item 5 above. - A streamed request body (
raw_content is None) is treated as oversize, not unmeasured. This is deliberate (gate.py's comment: "treat None as oversize so a future stream opt-in can't silently bypass the cap"), so enabling request streaming anywhere in the chain will start blocking requests that previously worked, withbody_too_large, not a clearer error. - External-app matcher exceptions fail open to off-catalog, not closed.
MCP classification failures on an attributed server fail closed (
DENY). A bug inrecognize_actionsor the URL/glob matching does not deny the request; it makes the request look like ordinary (ungated) traffic to a host-claiming resolver. This is a deliberate trade-off (the matcher is a heuristic classifier, not the security boundary) but it means a matching bug is silent from a security-audit standpoint: nothing 403s, the action just stops being gated. - The MCP session-tag header is a same-user attribution hint, not a
security boundary (
gate.py's own comment onMCP_SESSION_TAG_HEADER). A compromised sandbox process can forge it to attribute an MCP tool-call approval to any of that same user's sessions, never another user's. Do not add "sign this header" as a fix; the value is already stored in-sandbox in plaintext, so signing buys nothing. app_typeis immutable and apps aren't identified by name. Two self-hosted GitLab/Jira instances, or two MCP servers, can share a display name; every lookup here keys off(kind, id)orexternal_app_id, neverapp_name. A change that starts matching or granting by name will silently collide across such instances (see[[craft-admin]]§9).- The CA's blast radius is every sandbox in the deployment, not one
session. Rotating it, or a bug that stops the K8s
ConfigMap(or the Docker volume'sca.crt) from projecting correctly, breaks TLS for every running sandbox simultaneously, not just new ones, since existing sandboxes only refresh their trust store at boot.