41 KiB
Egress Interception & Secrets
Objective
Give the Craft sandbox a way to call external services — Linear, HubSpot, GitHub, internal APIs — without ever seeing the credentials and without ever performing an external write that nobody approved. Today the sandbox either has no path to external systems at all, or (if a skill author hardcodes a token) has a raw API key sitting in environment variables alongside the agent's bash tool. Neither is acceptable for an enterprise product.
V1 introduces an Onyx-managed egress proxy. All sandbox HTTP/HTTPS traffic is routed through it via standard HTTP_PROXY/HTTPS_PROXY env vars; the sandbox image trusts an Onyx-generated CA so the proxy can MITM TLS. Skills call upstream URLs in the normal way (https://api.linear.app/graphql); the proxy resolves the request to a registered service, classifies it (read / write / delivery / destructive / unknown), injects credentials server-side for allowlisted requests, and forwards. Writes that are policy-gated pause the request, raise an approval, and replay on approve. The sandbox never receives a raw token, ever.
This is also the enforcement point for project #6 (Approvals) and the substrate that project #5 (OAuth for External Apps) layers per-user tokens onto.
Issues to Address
- No way to give the sandbox external reach without leaking secrets. Skill authors today have to either skip external integrations entirely or stash an API key in the sandbox image / session env. The agent's bash tool can
cat $LINEAR_API_KEYwhenever it wants — and so can any prompt-injection vector that reaches the agent. - No first-line enforcement of "writes need approval." Even with great prompting, the agent can be coerced or convinced to call an upstream
POST /issuesdirectly. Approval enforcement at the prompt layer is best-effort guidance; the actual bytes go on the wire whenever the tool fires. We need an Onyx-controlled boundary that physically intercepts the request before it leaves the deployment. - Two parallel external-call paths today. Onyx custom tools call upstreams from the main app with OAuth/API-key injection (
backend/onyx/tools/tool_implementations/custom/custom_tool.py). Federated connectors do the same from their own modules (backend/onyx/db/federated.py). Neither runs from inside the sandbox — both are server-side calls invoked from chat. Craft skills need their own path because they run inside the sandbox process, but we should not invent a third credential model. - No classification of upstream requests. "API call" is one bucket today. We need to distinguish reads (
GET /issues) from writes (POST /issues) from deliveries (POST /messages,POST /emails) from destructive operations (DELETE /repo) so policy can attach to the dangerous ones without bottlenecking the safe ones. - No audit of which upstreams a session actually called. When a Craft trigger runs overnight and the customer asks "what did it do?", we want a precise list (HubSpot at 02:14, two reads; Linear at 02:15, one write — approved by user X) — not a guess from prompt logs.
Important Notes
- The proxy is a security-critical, single-purpose component. It is the only place in the deployment that decrypts external-service secrets, and the only enforcement point for "this write needs approval before it goes out." Treat its surface area like an authentication boundary: small, dedicated, well-tested, restartable independently of the rest of Onyx.
- HTTP_PROXY interception, not a transparent network appliance. Self-hosted admins should not need to reconfigure iptables, run a sidecar with
NET_ADMIN, or own a CNI plugin to use Craft. We use the standardHTTPS_PROXYenv var supported by every HTTP library the sandbox runs (Node fetch, Pythonrequests/urllib3/httpx,curl,git). The trade-off — software that explicitly bypassesHTTPS_PROXY(rare) escapes interception — is acceptable for V1; we mitigate by also blocking direct egress at the network layer where the backend supports it (Docker network policy, K8s NetworkPolicy) so non-proxy traffic is dropped, not silently allowed. - TLS MITM via an Onyx-generated CA. The proxy generates its own root CA at first boot, signs leaf certs on demand for each
CONNECTtarget, and the sandbox image trusts that root via/usr/local/share/ca-certificates/. Node and Python pick it up viaNODE_EXTRA_CA_CERTSandREQUESTS_CA_BUNDLE/SSL_CERT_FILEenv vars set in the sandbox. The CA private key is encrypted at rest with the existing Onyx encryption key (ENCRYPTION_KEY_SECRET), regenerated only on explicit admin reset. - Sandbox never sees decrypted secrets. This is not a goal we can soften under any circumstance. The proxy is the only process that calls
decrypt_bytes_to_stringforCraftSecretrows. Secrets do not pass through the api_server, the sandbox, the agent, or any artifact. The sandbox's view of the world is "I calledhttps://api.linear.app/graphqland got200 OK." - Non-secret internet access defaults to pass-through. Settled in the main plan. If the sandbox wants to
curl https://example.com/index.htmlfor a public URL, the proxy lets it through with no credential injection. We do not become an internet allowlist by default. (Admins can flip ablock-by-defaultdeployment flag if they want lockdown; not the default.) - Reuse, don't fork, the existing OAuth machinery.
OAuthConfigandOAuthUserTokenalready store encrypted client credentials and per-user tokens for custom tools (backend/onyx/db/models.py:3357,3401). They are the basis for project #5 (OAuth Apps). Project #4 — this doc — handles the org-wide secret case (an API key shared across all Craft sessions for a tenant); it does not duplicate the per-user OAuth machinery, it pre-builds the proxy resolver in a way that project #5 layers per-user tokens onto cleanly. - Classification is per-service, not per-request, in V1. Each
CraftInterceptedServicecarries its own simple classification rules — by HTTP method, URL path prefix, GraphQL operation name. The proxy applies them at request time and tags the request asREAD/WRITE/DELIVERY/DESTRUCTIVE/UNKNOWN. We do not attempt to deep-parse arbitrary upstream APIs.UNKNOWNdefaults to "treat as write" — opinionated and safe; admins can override per service. UNKNOWNrequires approval by default. If we don't know what a request does, we don't let it through unattended. The exception is internal/non-secret destinations (no service registered) — those default to pass-through, as above.- Approvals enforcement lives here. When the proxy classifies a request as a category that requires approval (per the service's
approval_policy), it pauses the request, opens an approval record (project #6), and either returns a synthetic 202-style response to the sandbox or holds the connection open until decision. The proxy holds an encrypted snapshot of the original request (URL, headers minus auth, body, idempotency key) so it can replay byte-for-byte after approval — without re-deriving the agent's intent. Snapshots are short-lived (configurable; default 24h) and encrypted withENCRYPTION_KEY_SECRET. - Idempotency keys are mandatory for write replay. When a request is paused for approval, the proxy stamps an idempotency key into the snapshot. On approval, the proxy sets the upstream-appropriate idempotency header (
Idempotency-Key,X-Idempotency-Key, etc.) per the service definition, so retries don't double-write. Writes that we've classified but for which the upstream has no idempotency support are flagged in the service definition (supports_idempotency = false) and approved-and-replayed at the user's risk; those services are surfaced in the admin UI with a "no idempotency" badge. - Per-tenant grants are explicit. A
CraftInterceptedServiceis registered by an Onyx admin and gated by aCraftInterceptedServiceGrant(org-wide, group, or user). Sandbox sessions inherit the calling user's grants. Self-hosted single-tenant deployments mostly use org-wide grants; multi-tenant cloud deployments will lean on per-group or per-user. - Audit is project #9, not this project. This doc emits structured events at every interesting transition (request received / classified / injected / approved / replayed / denied / passed-through). Persistence and admin queries belong to the run-audit project. Don't replicate that work here.
- Local sandbox backend bypasses the proxy.
localmode is dev-only and runs on the host. Routing it through the proxy adds operational complexity for self-hosted dev laptops without security value (the agent is already running as the api_server user — the secret leak threat model doesn't apply). Keeplocaldirect; requiredockerorkubernetesfor production Craft. - No backwards compatibility. Per the main plan, V1 has no migration story for existing Craft state. If a skill author shipped a hardcoded token in an attachment, that token works once, won't work after the next session bootstrap, and the admin sees it called out in the egress logs.
Approaches Considered
A. Inject credentials at the tool layer in the main app (rejected)
Keep external-API calls in the main app the way custom tools do today; expose them to the sandbox as Onyx-side HTTP endpoints. The sandbox calls POST /api/build/sandbox/external/linear/issues instead of POST https://api.linear.app/graphql. The api_server holds the secret and forwards.
Why rejected: It re-implements every upstream API as a typed Onyx route. Skill authors lose the ability to call any upstream directly — they can only call what we've shimmed. Skill portability with the broader Codex/OpenCode ecosystem (a stated goal of project #3) breaks immediately, because their skills assume real upstream URLs. We'd be building a service mesh's worth of bespoke routes for no security gain over a transparent proxy.
B. Transparent network appliance with iptables / eBPF redirection (rejected)
Run a sidecar with NET_ADMIN capability that DNATs all egress through the proxy regardless of HTTP_PROXY. Catches every TCP connection.
Why rejected: Operationally heavy for self-hosted (the docker backend would need a privileged sidecar; Linux kernel/network stack assumptions vary across distros and Docker Desktop). Cloud K8s gets it via NetworkPolicy + service mesh, but that doesn't help self-hosted compose users who are the bigger pain point. The marginal security win — catching software that explicitly bypasses HTTPS_PROXY — is real but small; almost nothing the agent runs does that. We can re-evaluate after V1 if we see a concrete bypass case in production.
C. Per-upstream sidecars (rejected)
A pattern where each registered upstream gets its own small process (e.g. a "Linear sidecar") that the sandbox calls, which forwards to Linear with the secret injected.
Why rejected: Doesn't scale. Every new service is a new process, deployment, healthcheck, and version. Centralizing classification and approval logic gets harder, not easier. Tools like kong or per-service envoyfilter exist for a reason in larger environments, but they're way more infra than a 5-person team running self-hosted Onyx wants.
D. mitmproxy-based forward proxy in the Onyx monorepo (winner)
A new in-monorepo Python service, onyx-craft-egress, built on mitmproxy's proxy library (specifically the mitmproxy.proxy and mitmproxy.addons machinery — not the interactive UI). Listens for CONNECT-style HTTPS traffic from the sandbox, terminates TLS using a leaf cert signed by the Onyx-generated CA, classifies the request against CraftInterceptedService rules in Postgres, optionally injects credentials from CraftSecret rows, forwards upstream, streams the response back. Approval-gated requests pause via the standard mitmproxy intercept hook and resume on approval.
Why this wins:
HTTP_PROXY/HTTPS_PROXYis universal. Every HTTP runtime in the sandbox respects it (fetch,node-fetch,axios,requests,httpx,urllib3,curl,git,pip,npm). One env var pair and we're intercepting the world the agent actually uses.mitmproxyis the canonical, well-audited Python implementation of TLS MITM. It owns the awkward parts: per-host leaf cert generation, ALPN negotiation for HTTP/2, CONNECT semantics, websocket passthrough. We get those for free and write only Onyx-specific addons (resolver, classifier, injector, approver).- Same monorepo, same encryption key, same Postgres. No new protocol between this proxy and the api_server — it reads
CraftInterceptedServiceandCraftSecretdirectly from the same DB (with a small read-only connection pool). Echoes the "skip the indirection" call from the docker-backend doc. - Process-level isolation. Runs as its own service in compose / its own deployment in helm, so its blast radius is small and it can be restarted without touching api_server. Different reliability and security profile than the api_server warrants — the proxy must not crash on a malformed sandbox request and must not leak between concurrent requests.
- Approval gating fits the mitmproxy lifecycle naturally. mitmproxy already exposes a per-flow
intercept/resumeAPI that we use to pause requests pending approval — no exotic state machine. - Future upgrade path. If we ever want eBPF / transparent interception (approach B) or a multi-tenant gateway, we can keep the same
CraftInterceptedService+CraftSecretdata model and swap the listening surface. The DB schema is the contract.
Key Design Decisions
onyx-craft-egressis a new service in the monorepo. Code lives atbackend/onyx/craft/egress_proxy/. Started by the same Python entrypoint pattern as the api_server (python -m onyx.craft.egress_proxy). One process, asyncio-based, listens on a single configurable port (default8444).- mitmproxy as a library, not a CLI. We import
mitmproxy.proxy.master,mitmproxy.proxy.config, and the addon hook surface; we do not runmitmproxy/mitmweb/mitmdump. Onyx writes a small set of addons (resolver, classifier, injector, approver, audit emitter) and registers them with the master. Pinned to a specific mitmproxy version. - Onyx-generated CA, encrypted at rest. On first boot, the proxy generates a 4096-bit RSA root CA, encrypts the private key with the existing
ENCRYPTION_KEY_SECRETFernet key, and stores both the encrypted key and the public cert in a newCraftEgressCAtable (singleton row per tenant). Leaf certs for upstream hosts are generated on demand and held in an LRU cache keyed by SNI. The CA cert is exposed at a sandbox-only endpoint (GET /api/build/sandbox/egress/ca.pem, authenticated by the session token from project #1 insearch-design.md) sosetup_session_workspacecan fetch and install it; for image-baked deployments where the CA is stable, it's also written into the sandbox image at build time. - Sandbox setup writes proxy env vars.
setup_session_workspace(in eachSandboxManager) sets:HTTPS_PROXY=http://onyx-craft-egress:8444HTTP_PROXY=http://onyx-craft-egress:8444NO_PROXY=localhost,127.0.0.1,api_server,<onyx-internal-hosts>(so the sandbox can reach the api_server forcompany_searchetc. without round-tripping through the proxy)NODE_EXTRA_CA_CERTS=/etc/ssl/certs/onyx-craft-ca.crtREQUESTS_CA_BUNDLE=/etc/ssl/certs/onyx-craft-ca.crtSSL_CERT_FILE=/etc/ssl/certs/onyx-craft-ca.crtThe CA cert file is materialized into the session workspace (or baked into the image) before the OpenCode process starts.
- Sandbox auth to the proxy is the existing session token. The proxy expects a
Proxy-Authorization: Bearer <ONYX_BUILD_SESSION_TOKEN>header on every CONNECT/request. (HTTP_PROXYlibraries forward the env varHTTPS_PROXY=http://user:token@host:portand add the Proxy-Authorization header automatically; we use the formhttp://session:<TOKEN>@onyx-craft-egress:8444.) The proxy resolves token →BuildSession→User→ tenant exactly the way the search endpoint does (require_sandbox_session_tokenfrom project #1). Requests without a valid token are rejected with407 Proxy Authentication Required. - Service resolution is host-prefix-based, deterministic. When the proxy sees a request to
https://api.linear.app/graphql, it looks upCraftInterceptedServicerows whoseupstream_hostsincludesapi.linear.app. Most-specific match wins (exact host > suffix wildcard*.atlassian.net). If no service matches, the request is treated as non-secret pass-through (no injection, no approval — but still logged and TLS-MITM'd for visibility, configurable per-deployment). - Classification is rule-based per service. Each
CraftInterceptedServicecarries an inlineclassification_rulesJSONB list. A rule is a conjunction of:method(HTTP verb),path_pattern(glob), optionalbody_match(a JSONPath + regex pair, used for GraphQL operation classification), and acategory(READ/WRITE/DELIVERY/DESTRUCTIVE). First match wins. No match →UNKNOWN. Built-in services (Linear, HubSpot, GitHub, etc.) ship with a curated rule set; custom services start with a permissive default the admin can tighten. - Approval policy is
category → mode. A service'sapproval_policyJSONB is a mapping like{"READ": "allow", "WRITE": "approve_unless_owner", "DELIVERY": "approve_always", "DESTRUCTIVE": "approve_always", "UNKNOWN": "approve_always"}. Valid modes:allow(no approval),approve_unless_owner(auto-approve if the session's owner is acting interactively, approve for triggers and others),approve_always,deny(never run). Admin defaults are sensible for built-in services and editable. - Approvals integrate via a single intercept hook. The proxy's
approveraddon, when it sees a request that needs approval, callscraft_approvals.create_request(session_id, snapshot, category)from project #6. That function returns a future-like handle; the addon awaits it with a per-service timeout (default 1h, configurable) and either resumes the flow on approve or returns a structured403 Approval Deniedpayload to the sandbox. The flow is held on the proxy side; the sandbox sees a long-running HTTPS request, no exotic protocol. - Snapshot for replay is encrypted, short-lived, and minimal. Stored in a new
CraftEgressApprovalSnapshottable:(approval_id, encrypted_request_body, content_type, idempotency_key, expires_at). URL, method, and host are kept on the parent approval record (not encrypted, used for the UI). Headers are sanitized — auth headers stripped before storage, since they'll be re-derived on replay. TTL default 24h; expired snapshots are rejected on replay even if the approval is still pending. Cleanup runs on the standard celerylightworker. - Direct external egress is blocked at the backend level when possible. Docker backend: the sandbox container joins only the
onyx_craft_sandboxbridge network (already proposed insandbox-backends.md); the proxy is on that network, the public internet is not. Outbound traffic to anything other than the proxy fails at TCP. Kubernetes backend: aNetworkPolicyon the sandbox namespace allows egress only to the egress-proxy service.localbackend: no enforcement (dev only). This belt-and-suspenders approach catchesHTTPS_PROXY-bypass attempts. - Audit emission is structured. The proxy emits one event per request lifecycle transition:
received,resolved,classified,gated,approved/denied,injected,forwarded,responded,passthrough. Events are written to a Postgres outbox table the audit project consumes. The proxy never logs request bodies or response bodies; it logs URLs (with query-string params hashed for cardinality control), categories, and decision metadata.
Architecture
┌────────────────────── Sandbox (docker container / k8s pod) ──────────────────────┐
│ │
│ OpenCode agent + skills │
│ │ │
│ │ e.g. curl -X POST https://api.linear.app/graphql ... │
│ │ honors HTTPS_PROXY env var │
│ ▼ │
│ HTTPS_PROXY=http://session:<TOK>@onyx-craft-egress:8444 │
│ Trusts /etc/ssl/certs/onyx-craft-ca.crt │
└──────────────────────────────────┬───────────────────────────────────────────────┘
│ CONNECT api.linear.app:443
│ + Proxy-Authorization: Bearer <session token>
▼
┌──────────────────────── onyx-craft-egress (new service) ─────────────────────────┐
│ │
│ mitmproxy.proxy.master ┌─ session-token resolver ──┐ │
│ │ │ token → session, user, │ │
│ ▼ │ tenant, grants │ │
│ addons: └────────────────────────────┘ │
│ 1. resolver ──────► CraftInterceptedService lookup (Postgres, RO) │
│ 2. classifier ─────► match classification_rules → category │
│ 3. approver ──────► if approval needed: snapshot + craft_approvals.create_request
│ await decision (or timeout) │
│ 4. injector ──────► decrypt CraftSecret, set Authorization / custom header │
│ 5. forwarder ──────► upstream (real TLS via outbound httpx client) │
│ 6. audit ──────► outbox: events for project #9 │
│ │
│ Onyx CA (encrypted at rest in CraftEgressCA singleton row) │
│ Per-host leaf cert cache (LRU, in-memory) │
└──────────────────────────────────┬───────────────────────────────────────────────┘
│ TLS to api.linear.app
▼
┌─────────────────┐
│ External API │
└─────────────────┘
Per-request flow (write, approval-gated)
sandbox egress proxy approvals (project #6)
│ │ │
│ CONNECT api.linear.app:443 │ │
├───────────────────────────►│ │
│ ... TLS handshake (leaf signed by Onyx CA) ... │
│ │ │
│ POST /graphql {...mutation create issue...} │
├───────────────────────────►│ │
│ │ classify → WRITE │
│ │ snapshot request, idempotency key │
│ ├──── create_request ───────────────►│
│ │ │ (UI prompt
│ │ │ → user approve)
│ │◄────────── approved ───────────────│
│ │ inject Authorization, replay │
│ ├─── upstream POST ───► Linear API │
│ │◄─── 201 {...} ────── │
│ 201 {...} │ │
│◄───────────────────────────│ │
Data Model Changes
All new tables live alongside existing Craft/Build models (backend/onyx/db/models.py), seeded by a new alembic migration. No changes to existing tables in this project (a craft_token column is needed but is added by project #1 — search).
class CraftEgressCA(Base):
"""Singleton (per-tenant) CA used to MITM sandbox TLS."""
__tablename__ = "craft_egress_ca"
id: Mapped[int] = mapped_column(primary_key=True)
cert_pem: Mapped[str] # public, plain
encrypted_key_pem: Mapped[SensitiveValue[str]] = mapped_column(EncryptedString())
created_at, updated_at, rotated_at, fingerprint: ...
class CraftInterceptedService(Base):
__tablename__ = "craft_intercepted_service"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True) # e.g. "Linear"
slug: Mapped[str] = mapped_column(unique=True) # e.g. "linear"
upstream_hosts: Mapped[list[str]] = mapped_column(JSONB) # ["api.linear.app"]
auth_scheme: Mapped[CraftAuthScheme] # API_KEY | BEARER | HEADER | HMAC | NONE
auth_template: Mapped[dict] = mapped_column(JSONB) # e.g. {"header":"Authorization","value":"Bearer {{secret}}"}
classification_rules: Mapped[list[dict]] = mapped_column(JSONB)
approval_policy: Mapped[dict] = mapped_column(JSONB) # category -> mode
supports_idempotency: Mapped[bool]
idempotency_header: Mapped[str | None] # e.g. "Idempotency-Key"
is_built_in: Mapped[bool] # seeded vs admin-created
enabled: Mapped[bool]
created_at, updated_at: ...
class CraftSecret(Base):
"""Org-wide secret material for an intercepted service."""
__tablename__ = "craft_secret"
id: Mapped[int] = mapped_column(primary_key=True)
service_id: Mapped[int] = mapped_column(ForeignKey("craft_intercepted_service.id"))
name: Mapped[str] # e.g. "default" or "publish"
encrypted_value: Mapped[SensitiveValue[str]] = mapped_column(EncryptedString())
last_rotated_at: Mapped[datetime | None]
created_at, updated_at: ...
__table_args__ = (UniqueConstraint("service_id", "name"),)
class CraftInterceptedServiceGrant(Base):
__tablename__ = "craft_intercepted_service_grant"
id: Mapped[int] = mapped_column(primary_key=True)
service_id: Mapped[int] = mapped_column(ForeignKey("craft_intercepted_service.id"))
scope_type: Mapped[CraftGrantScope] # ORG | GROUP | USER
scope_id: Mapped[UUID | None] # null for ORG
approval_policy_override: Mapped[dict | None] = mapped_column(JSONB) # optional per-grant override
created_at, updated_at: ...
class CraftEgressApprovalSnapshot(Base):
__tablename__ = "craft_egress_approval_snapshot"
id: Mapped[int] = mapped_column(primary_key=True)
approval_id: Mapped[int] = mapped_column(ForeignKey("craft_approval.id", ondelete="CASCADE"))
method: Mapped[str]
url: Mapped[str] # not encrypted (UI shows it)
encrypted_body: Mapped[SensitiveValue[bytes]] = mapped_column(LargeBinary)
content_type: Mapped[str]
idempotency_key: Mapped[str]
expires_at: Mapped[datetime]
created_at: ...
CraftAuthScheme, CraftGrantScope, and the egress event categories are new enums in backend/onyx/db/enums.py. The audit event outbox table is owned by project #9 (audit.md), not added here.
API Specs
Admin (under /api/admin/craft/intercepted-services, cookie-authenticated, admin-only)
GET /api/admin/craft/intercepted-services
→ list of services (with grant + secret summary, no decrypted values)
POST /api/admin/craft/intercepted-services
body: { name, slug, upstream_hosts, auth_scheme, auth_template,
classification_rules, approval_policy, supports_idempotency,
idempotency_header, enabled }
→ 201 with the created service id
PATCH /api/admin/craft/intercepted-services/{id}
body: any subset of the above fields
→ 200
DELETE /api/admin/craft/intercepted-services/{id}
→ 204 (cascades grants and secrets)
POST /api/admin/craft/intercepted-services/{id}/secrets
body: { name, value } # value is the raw secret; encrypted on write
→ 201
POST /api/admin/craft/intercepted-services/{id}/secrets/{secret_id}/rotate
body: { value }
→ 200
DELETE /api/admin/craft/intercepted-services/{id}/secrets/{secret_id}
→ 204
POST /api/admin/craft/intercepted-services/{id}/grants
body: { scope_type, scope_id?, approval_policy_override? }
→ 201
DELETE /api/admin/craft/intercepted-services/{id}/grants/{grant_id}
→ 204
POST /api/admin/craft/intercepted-services/{id}/test
body: { method, path, sample_body? }
→ { matched_service: {id, slug}, classification: "WRITE",
would_inject: true, would_require_approval: true }
# Dry-run a request through resolver+classifier without forwarding.
Sandbox-side (under /api/build/sandbox/egress, session-token-authenticated)
GET /api/build/sandbox/egress/ca.pem
→ text/plain PEM of the Onyx CA cert. Cached aggressively.
# Used by setup_session_workspace if the sandbox image isn't pre-baked
# with the cert (e.g. when the admin rotated the CA after image build).
GET /api/build/sandbox/egress/services
→ list of available services for this user, with display metadata
(name, slug, base host(s), category labels). No secret material.
# Used by skills/SKILL.md generation to advertise available upstreams
# to the agent.
Egress proxy listener
PROXY any URL
Proxy-Authorization: Bearer <session token>
# Standard HTTP CONNECT and direct-HTTP forms; not a typed Onyx route.
The proxy itself is not exposed as an Onyx-app FastAPI route — it speaks HTTP-proxy protocol on its own port. Admin observability of the proxy goes through /api/admin/craft/egress/health (proxied to the egress service via the api_server) which returns { ok, ca_fingerprint, in_flight_requests, last_error }.
Relevant Files / Onyx Subsystems
New: egress proxy module:
backend/onyx/craft/egress_proxy/__init__.py— module shell.backend/onyx/craft/egress_proxy/__main__.py—python -m onyx.craft.egress_proxyentrypoint; builds the mitmproxyMaster, registers addons, runs the loop.backend/onyx/craft/egress_proxy/config.py—EgressProxyConfigdataclass (port, DB DSN, CA path, mitmproxy options); reads from env.backend/onyx/craft/egress_proxy/ca.py— load-or-generate CA, decrypt private key on startup, leaf-cert generation cache.backend/onyx/craft/egress_proxy/auth.py—Proxy-Authorizationvalidator → resolves session token to(BuildSession, User, Tenant). Uses the same cache strategy asrequire_sandbox_session_tokenfrom project #1.backend/onyx/craft/egress_proxy/addons/resolver.py— host → service lookup (with positive + negative caching).backend/onyx/craft/egress_proxy/addons/classifier.py— applyclassification_rules, return category.backend/onyx/craft/egress_proxy/addons/approver.py— gate the request; talk tocraft_approvals(project #6) over a small in-process API.backend/onyx/craft/egress_proxy/addons/injector.py— decryptCraftSecret, renderauth_template, set headers.backend/onyx/craft/egress_proxy/addons/audit.py— emit lifecycle events to the audit outbox (project #9).backend/onyx/craft/egress_proxy/outbound.py— forward to upstream viahttpx.AsyncClient; tear down/replay paths.
New: DB models + alembic migration:
backend/onyx/db/models.py— five new tables described above.backend/onyx/db/enums.py—CraftAuthScheme,CraftGrantScope,CraftEgressCategory.backend/alembic/versions/<rev>_craft_egress_interception.py— schema migration.backend/onyx/db/craft_intercepted_service.py— CRUD helpers (mirrorsoauth_config.py).backend/onyx/db/craft_egress_secret.py— secret create / rotate / delete with explicit decrypt-only-where-needed pattern.
New: admin & sandbox routes:
backend/onyx/server/features/build/api/admin_intercepted_services.py— admin CRUD endpoints described above.backend/onyx/server/features/build/api/sandbox_egress.py—/ca.pemand/services(session-token auth).backend/onyx/server/features/build/api/admin_egress_health.py— proxies a healthcheck to the egress service.
Existing files to touch:
backend/onyx/server/features/build/sandbox/base.py—setup_session_workspacewrites the proxy env vars and CA cert. Each backend implements its own version of "materialize CA" (image-baked for K8s, env-injected for docker, none for local).backend/onyx/server/features/build/sandbox/local/local_sandbox_manager.py— explicit no-op for proxy wiring; document thatlocalis dev-only.backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py(when project #2 lands) — sandbox containers joinonyx_craft_sandboxnetwork; proxy is also on that network.backend/onyx/server/features/build/sandbox/kubernetes/kubernetes_sandbox_manager.py— sandbox pod gets a NetworkPolicy egress-allowlisted to the egress-proxy service; CA cert mounted via ConfigMap or baked into image.backend/onyx/server/features/build/sandbox/kubernetes/docker/Dockerfile— install Onyx CA into/etc/ssl/certs/. CA fingerprint baked at image build, replaceable via the/ca.pemendpoint at session setup if the deployment has rotated.backend/onyx/server/features/build/sandbox/util/opencode_config.py— no change today; future skill-level "this skill needs service X" hint is left for project #3.backend/onyx/server/features/build/AGENTS.template.md— agent guidance: "to call external services, just use their normal URLs; Onyx will inject credentials and prompt the user for approval where needed."
Deployment / packaging:
deployment/docker_compose/docker-compose.yml— newonyx-craft-egressservice:- image:
onyxdotapp/onyx-backend:<tag>(same image; different command), - command:
python -m onyx.craft.egress_proxy, - depends_on:
relational_db,redis, - on the
onyx_craft_sandboxnetwork, ENCRYPTION_KEY_SECRETandPOSTGRES_*env vars passed in. No new image; the egress proxy runs from the existing backend image.
- image:
deployment/helm/charts/onyx/templates/craft-egress-deployment.yaml— k8s deployment + service for the proxy.deployment/helm/charts/onyx/templates/craft-sandbox-networkpolicy.yaml— namespaced NetworkPolicy restricting sandbox egress to the proxy.backend/requirements/default.txt— addmitmproxy>=10,<11(heavy-ish — pulls cryptography, h2, kaitaistruct; ~30MB image growth). Pin tightly.
Seed data:
backend/onyx/craft/egress_proxy/builtin_services.py— initialCraftInterceptedServicerows for Linear, HubSpot, GitHub, Slack, Google Calendar, generic OpenAI/Anthropic-compatible endpoints. Seeded via a one-time alembic data migration; admins can edit, disable, or delete after seed.
Tests
Lightweight, focused on the boundary properties the proxy promises. The mitmproxy library has its own deep test suite — we don't re-test TLS, CONNECT, or HTTP/2 framing.
Unit (small, no daemon):
backend/tests/unit/onyx/craft/egress_proxy/test_classifier.py— feed synthetic(method, path, body)tuples through the classifier with a fixtureCraftInterceptedServiceset; assert the right category. Cover the GraphQL operation-name path (POST /graphqlwithbody_matchrule).backend/tests/unit/onyx/craft/egress_proxy/test_resolver.py— host-prefix matching: most-specific wins, exact > wildcard, no-match → pass-through marker.backend/tests/unit/onyx/craft/egress_proxy/test_auth_template.py— render auth templates:Bearer {{secret}},Basic {{secret}}, custom-header form, HMAC. Confirms secret values never appear in any audit/log output (usecaplogto assert absence).backend/tests/unit/onyx/craft/egress_proxy/test_ca.py— generate-on-first-boot, encrypt with the test fernet key, decrypt at startup, leaf-cert cache eviction.
External dependency unit (one file, the load-bearing one):
backend/tests/external_dependency_unit/craft/egress_proxy/test_egress_e2e.py
- Spins up the proxy as a subprocess against the live test DB, with a dummy
CraftInterceptedServiceforhttpbin.organd aCraftSecret"supersecret". - A test client points its
HTTPS_PROXYat the proxy and trusts the freshly-generated test CA. - Assertions:
GET /headers→ response includes"Authorization": "Bearer supersecret"(proxy injected); the test client never set it.POST /post→ classified as WRITE; with defaultapprove_always, an approval row is created and the request hangs; auto-approving via direct DB write resumes the flow with the same body.DELETE /delete→ classified as DESTRUCTIVE; withdeny, returns403.- Without a
Proxy-Authorizationtoken, returns407. - With an invalid/expired token, returns
407. GET https://example.com/(no service registered) → pass-through with no Authorization header injected; emit apassthroughaudit event.
- Skipped on CI runners without an outbound network; mark with the existing skip pattern.
Integration (one file):
backend/tests/integration/tests/build/test_sandbox_egress.py
- Uses the standard Craft session integration fixtures.
- Boots a real sandbox session, runs a skill that does
curl https://api.test-fixture.local/...(test fixture host registered as aCraftInterceptedService). - Asserts the request reached the test fixture upstream with the injected header, and that the audit outbox has the expected event sequence.
- Negative path: register a service with
approve_alwaysfor WRITE; have the skill POST; assert the session is left inWAITING_FOR_APPROVALand the request didn't reach the upstream until the test approves it. - We do not matrix this across all three sandbox backends — running it against
dockeris enough.
Playwright: none in this project. Admin UI for managing intercepted services is part of project #8 (admin-ui.md), and gets its own E2E tests there.
Manual smoke (do this before merging):
docker compose upwithSANDBOX_BACKEND=dockerandENABLE_CRAFT=true. Confirm theonyx-craft-egresscontainer is up. Open a Craft session, run an OpenCode skill that doescurl -v https://httpbin.org/headers. Confirm:- The
curlsucceeds (TLS handshake against the Onyx CA). httpbinechoes back anAuthorizationheader that the agent never sees.- The egress audit log shows
received → classified READ → injected → forwarded → responded.
- The
- Approval path: configure a service with
approve_alwaysfor WRITE. Have the agent POST. Confirm the session UI shows a pending approval; approve it; confirm the upstream POST goes through with the snapshot's body andIdempotency-Key. - Pass-through path: agent fetches a public URL with no service registered. Confirm it works, no auth injected, audit shows
passthrough. - Direct-egress block: from the sandbox container, attempt
curl --noproxy '*' https://api.linear.app. Confirm the connection is refused (NetworkPolicy / bridge network blocks it). - CA rotation: rotate the CA in the admin UI, restart the proxy, confirm the next session picks up the new CA cert via
/api/build/sandbox/egress/ca.pemand the previous session's existing connections fail-and-retry cleanly.
That's enough for V1. Heavier coverage — fuzzing the proxy with malformed CONNECT, HTTP/2 stream-level abuse, large-body replay edge cases, multi-replica DB read consistency — waits until we've seen real traffic patterns from production.