diff --git a/.agents/feature-map/components/craft-external-apps.md b/.agents/feature-map/components/craft-external-apps.md index e439ac5d0a..4dfc50ffa0 100644 --- a/.agents/feature-map/components/craft-external-apps.md +++ b/.agents/feature-map/components/craft-external-apps.md @@ -13,9 +13,9 @@ **Domain:** craft **Edition:** CE, with a Cloud-only lockdown on built-in credential editing (see `[[craft-admin]]`) **Owns:** -`backend/onyx/sandbox_proxy/` (`server.py`, `backend.py`, `request_evaluator.py`, -`credential_injection.py`, `approval_cache.py`, `identity.py`, `identity_k8s.py`, -`identity_docker.py`, `ca.py`, `ca_k8s.py`, `ca_docker.py`, `errors.py`, +`backend/onyx/sandbox_proxy/` (`server.py`, `request_evaluator.py`, +`credential_injection.py`, `approval_cache.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`, @@ -89,9 +89,9 @@ time. | `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/backend.py:build_ca_store`, `build_ip_lookup`). | -| `SANDBOX_PROXY_CA_SECRET`, `SANDBOX_PROXY_CA_CONFIGMAP`, `SANDBOX_PROXY_NAMESPACE` | `configs.py` | K8s CA persistence and cross-namespace projection targets (`ca_k8s.py`). | -| `SANDBOX_PROXY_CA_VOLUME_PATH` | `configs.py` | Docker CA persistence volume (`ca_docker.py`). | +| `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 one internal 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`). | @@ -176,7 +176,7 @@ sandbox process (opencode / a tool call) ▼ 1. TLS termination (MITM) mitmproxy decrypts the CONNECT tunnel using the proxy's own CA - (ca.py, ca_k8s.py / ca_docker.py), which the sandbox was made to trust + (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. @@ -200,7 +200,7 @@ sandbox process (opencode / a tool call) 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/identity.py:IdentityResolver.resolve_sandbox + sandbox_proxy/sandbox_identity/resolution.py:IdentityResolver.resolve_sandbox │ 4. Body-size gate flow.request.raw_content is None (streamed body) or > 32 MiB @@ -258,13 +258,13 @@ sandbox process (opencode / a tool call) The proxy generates a self-signed CA (RSA-4096, 5-year validity, `x509.BasicConstraints(ca=True, path_length=0)`, -`sandbox_proxy/ca.py:CABootstrap._generate_ca`) once per deployment and +`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/ca_k8s.py:K8sSecretCAStore`); or a shared Docker Compose +(`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/ca_docker.py:FileCAStore`). Multiple proxy replicas cold-start +(`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`). @@ -294,9 +294,9 @@ component's code. Base identity (`sandbox_id`, `user_id`, `tenant_id`) is resolved purely from the TCP source IP of the connection into the proxy -(`sandbox_proxy/identity.py:IdentityResolver.resolve_sandbox`), against a +(`sandbox_proxy/sandbox_identity/resolution.py:IdentityResolver.resolve_sandbox`), against a cache the proxy itself builds by watching sandbox pods/containers -(`identity_k8s.py:K8sInformerLookup`, `identity_docker.py:DockerEventsLookup`). +(`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. @@ -304,7 +304,7 @@ so this layer is not spoofable from inside a sandbox in either backend. 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_id`s at initial sync -(`identity_k8s.py:_initial_list`, `identity_docker.py:_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 @@ -316,7 +316,7 @@ 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` -(`identity.py:IdentityResolver.resolve_session_by_id`), so a forged tag can +(`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` -> @@ -580,9 +580,9 @@ they can never disagree about which server owns a request; see | 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 (`identity.py`, `identity_k8s.py`, `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 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 (`ca.py`, `ca_k8s.py`, `ca_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 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 | diff --git a/backend/onyx/sandbox_proxy/addons/gate.py b/backend/onyx/sandbox_proxy/addons/gate.py index 960b5c691c..3f21709234 100644 --- a/backend/onyx/sandbox_proxy/addons/gate.py +++ b/backend/onyx/sandbox_proxy/addons/gate.py @@ -11,13 +11,13 @@ import ipaddress import operator import threading from collections.abc import Callable -from dataclasses import dataclass from typing import Protocol from uuid import UUID from cachetools import TTLCache, cachedmethod from mitmproxy import http from mitmproxy.proxy import server_hooks +from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session from onyx.cache.interface import CACHE_TRANSIENT_ERRORS, CacheBackend @@ -32,11 +32,7 @@ from onyx.external_apps.matching.engine import ( actions_requiring_approval, ) from onyx.sandbox_proxy import approval_cache -from onyx.sandbox_proxy.credential_injection import ( - CredentialInjectionDispatcher, - InjectionContext, - InjectionOutcome, -) +from onyx.sandbox_proxy.credential_injection import CredentialInjectionDispatcher from onyx.sandbox_proxy.destination_policy import ( clear_destination, is_destination_blocked, @@ -44,7 +40,6 @@ from onyx.sandbox_proxy.destination_policy import ( resolve_destination, ) from onyx.sandbox_proxy.errors import SandboxProxyError, http_403 -from onyx.sandbox_proxy.identity import ResolvedSandbox, SessionContext from onyx.sandbox_proxy.logging_utils import ( APPROVAL_DECIDED_FIELDS, EGRESS_APPROVAL_MATCHED_FIELDS, @@ -61,8 +56,13 @@ from onyx.sandbox_proxy.logging_utils import ( sandbox_log_label, short_log_id, ) -from onyx.sandbox_proxy.models import DestinationPolicyConfig +from onyx.sandbox_proxy.models import ( + DestinationPolicyConfig, + InjectionContext, + InjectionOutcome, +) from onyx.sandbox_proxy.request_evaluator import RequestEvaluator +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SessionContext from onyx.server.features.build.configs import ( MCP_SESSION_TAG_HEADER, SANDBOX_APPROVAL_WAIT_TIMEOUT_SECONDS, @@ -97,14 +97,15 @@ CacheFactory = Callable[[str], CacheBackend] _CRAFT_SESSION_LINK_TEMPLATE = "/craft/v1?sessionId={session_id}" -@dataclass(frozen=True) -class _ApprovalGrant: +class _ApprovalGrant(BaseModel): """A decision to approve a gated request without parking it. Produced by a grant source in ``_resolve_approval_grant``. Carries how to attribute the audit row (``decided_via``) and any bell entry to raise. """ + model_config = ConfigDict(frozen=True) + decided_via: ApprovalDecidedVia notif_type: NotificationType | None = None notification_title: str | None = None diff --git a/backend/onyx/sandbox_proxy/backend.py b/backend/onyx/sandbox_proxy/backend.py deleted file mode 100644 index 148b7449d8..0000000000 --- a/backend/onyx/sandbox_proxy/backend.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Backend-aware constructors for the swappable proxy stores. - -``server.py`` calls these instead of instantiating ``K8sSecretCAStore`` / -``K8sInformerLookup`` (or their docker analogues) directly so the proxy boots -correctly under either ``SANDBOX_BACKEND`` value. - -Imports of the K8s and docker concrete classes are deferred to the dispatch -call. This keeps each backend's heavy import (``kubernetes`` or ``docker``) out -of the other backend's process import graph, which matters at proxy boot where -the wrong-backend SDK opening config files on import would be wasted work at -best and crash-on-missing-config at worst. -""" - -from onyx.sandbox_proxy.ca import CAStore -from onyx.sandbox_proxy.identity import SandboxIPLookup -from onyx.server.features.build.configs import SANDBOX_BACKEND, SandboxBackend - - -def build_ca_store() -> CAStore: - if SANDBOX_BACKEND is SandboxBackend.KUBERNETES: - from onyx.sandbox_proxy.ca_k8s import K8sSecretCAStore - - return K8sSecretCAStore() - if SANDBOX_BACKEND is SandboxBackend.DOCKER: - from onyx.sandbox_proxy.ca_docker import FileCAStore - - return FileCAStore() - raise RuntimeError(f"Unsupported SANDBOX_BACKEND={SANDBOX_BACKEND!r}.") - - -def build_ip_lookup() -> SandboxIPLookup: - if SANDBOX_BACKEND is SandboxBackend.KUBERNETES: - from onyx.sandbox_proxy.identity_k8s import K8sInformerLookup - - return K8sInformerLookup() - if SANDBOX_BACKEND is SandboxBackend.DOCKER: - from onyx.sandbox_proxy.identity_docker import DockerEventsLookup - - return DockerEventsLookup() - raise RuntimeError(f"Unsupported SANDBOX_BACKEND={SANDBOX_BACKEND!r}.") diff --git a/backend/onyx/sandbox_proxy/ca.py b/backend/onyx/sandbox_proxy/certificate_authority/bootstrap.py similarity index 91% rename from backend/onyx/sandbox_proxy/ca.py rename to backend/onyx/sandbox_proxy/certificate_authority/bootstrap.py index ba366ffc30..4ce71001a7 100644 --- a/backend/onyx/sandbox_proxy/ca.py +++ b/backend/onyx/sandbox_proxy/certificate_authority/bootstrap.py @@ -2,7 +2,6 @@ import datetime as dt import os -from dataclasses import dataclass from pathlib import Path from typing import Protocol @@ -10,7 +9,9 @@ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID +from pydantic import BaseModel, ConfigDict +from onyx.server.features.build.configs import SANDBOX_BACKEND, SandboxBackend from onyx.utils.logger import setup_logger _CA_KEY_SIZE_BITS = 4096 @@ -40,8 +41,9 @@ class CAStore(Protocol): def persist(self, cert_pem: bytes, key_pem: bytes) -> None: ... -@dataclass(frozen=True) -class MaterializedCA: +class MaterializedCA(BaseModel): + model_config = ConfigDict(frozen=True) + cert_pem: bytes key_pem: bytes pem_path: Path @@ -202,3 +204,16 @@ class CABootstrap: return MaterializedCA( cert_pem=cert_pem, key_pem=key_pem, pem_path=self._pem_path ) + + +def build_ca_store() -> CAStore: + """Build the configured backend without importing the other backend SDK.""" + if SANDBOX_BACKEND is SandboxBackend.KUBERNETES: + from onyx.sandbox_proxy.certificate_authority.kubernetes import K8sSecretCAStore + + return K8sSecretCAStore() + if SANDBOX_BACKEND is SandboxBackend.DOCKER: + from onyx.sandbox_proxy.certificate_authority.docker import FileCAStore + + return FileCAStore() + raise RuntimeError(f"Unsupported SANDBOX_BACKEND={SANDBOX_BACKEND!r}.") diff --git a/backend/onyx/sandbox_proxy/ca_docker.py b/backend/onyx/sandbox_proxy/certificate_authority/docker.py similarity index 98% rename from backend/onyx/sandbox_proxy/ca_docker.py rename to backend/onyx/sandbox_proxy/certificate_authority/docker.py index ca3bfe7e53..86967d3a01 100644 --- a/backend/onyx/sandbox_proxy/ca_docker.py +++ b/backend/onyx/sandbox_proxy/certificate_authority/docker.py @@ -32,7 +32,10 @@ orphaned cert). Operator recovery is: delete ``ca.crt``, restart proxy. import os from pathlib import Path -from onyx.sandbox_proxy.ca import CAStore, CAStoreConflictError +from onyx.sandbox_proxy.certificate_authority.bootstrap import ( + CAStore, + CAStoreConflictError, +) from onyx.server.features.build.configs import SANDBOX_PROXY_CA_VOLUME_PATH from onyx.utils.logger import setup_logger diff --git a/backend/onyx/sandbox_proxy/ca_k8s.py b/backend/onyx/sandbox_proxy/certificate_authority/kubernetes.py similarity index 98% rename from backend/onyx/sandbox_proxy/ca_k8s.py rename to backend/onyx/sandbox_proxy/certificate_authority/kubernetes.py index 47ec057864..2e037de65b 100644 --- a/backend/onyx/sandbox_proxy/ca_k8s.py +++ b/backend/onyx/sandbox_proxy/certificate_authority/kubernetes.py @@ -13,7 +13,10 @@ import time from kubernetes import client from kubernetes.client.rest import ApiException -from onyx.sandbox_proxy.ca import CAStore, CAStoreConflictError +from onyx.sandbox_proxy.certificate_authority.bootstrap import ( + CAStore, + CAStoreConflictError, +) from onyx.server.features.build.configs import ( SANDBOX_NAMESPACE, SANDBOX_PROXY_CA_CONFIGMAP, diff --git a/backend/onyx/sandbox_proxy/credential_injection.py b/backend/onyx/sandbox_proxy/credential_injection.py index bfcc042b60..9546505d1c 100644 --- a/backend/onyx/sandbox_proxy/credential_injection.py +++ b/backend/onyx/sandbox_proxy/credential_injection.py @@ -10,15 +10,15 @@ dispatcher never raises. from __future__ import annotations -from dataclasses import dataclass -from enum import Enum from typing import Protocol from mitmproxy import http -from pydantic import BaseModel, ConfigDict -from onyx.external_apps.matching.engine import AllMatchedActions -from onyx.sandbox_proxy.identity import ResolvedSandbox +from onyx.sandbox_proxy.models import ( + InjectionContext, + InjectionOutcome, + InjectionResult, +) from onyx.utils.logger import setup_logger logger = setup_logger() @@ -36,19 +36,6 @@ class CredentialUnavailableError(Exception): self.sandbox_detail = sandbox_detail -@dataclass(frozen=True) -class InjectionContext: - """Per-request inputs every resolver receives. - - `matched_actions` is the actions matched for this request (carrying - `external_app_id`), or `None` on off-catalog forwards. `sandbox.tenant_id` is - what resolvers key their per-tenant lookups by. - """ - - sandbox: ResolvedSandbox - matched_actions: AllMatchedActions | None - - class CredentialResolver(Protocol): """One credential source: claims a request, then renders headers.""" @@ -68,23 +55,6 @@ class CredentialResolver(Protocol): ... -class InjectionOutcome(Enum): - PASS_THROUGH = "pass_through" - CLAIMED = "claimed" - INJECTED = "injected" - BLOCKED = "blocked" - - -class InjectionResult(BaseModel): - """Outcome of one dispatch. `block_detail` is agent-facing prose for the - 403 body, present only on BLOCKED when the resolver supplied one.""" - - model_config = ConfigDict(frozen=True) - - outcome: InjectionOutcome - block_detail: str | None = None - - class CredentialInjectionDispatcher: """First-claim-wins dispatch across a fixed list of resolvers.""" diff --git a/backend/onyx/sandbox_proxy/logging_utils.py b/backend/onyx/sandbox_proxy/logging_utils.py index c1e9bd6fc1..d58eeea832 100644 --- a/backend/onyx/sandbox_proxy/logging_utils.py +++ b/backend/onyx/sandbox_proxy/logging_utils.py @@ -6,8 +6,8 @@ from mitmproxy import http from onyx.db.enums import EndpointPolicy from onyx.external_apps.matching.engine import AllMatchedActions -from onyx.sandbox_proxy.credential_injection import InjectionOutcome -from onyx.sandbox_proxy.identity import ResolvedSandbox, SessionContext +from onyx.sandbox_proxy.models import InjectionOutcome +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SessionContext _EGRESS_CONTEXT_FIELDS = "tenant=%s sandbox=%s" _EGRESS_SESSION_FIELDS = f"{_EGRESS_CONTEXT_FIELDS} session=%s" diff --git a/backend/onyx/sandbox_proxy/mcp_jsonrpc.py b/backend/onyx/sandbox_proxy/mcp_jsonrpc.py index a1f04b4c2a..e0f3278f02 100644 --- a/backend/onyx/sandbox_proxy/mcp_jsonrpc.py +++ b/backend/onyx/sandbox_proxy/mcp_jsonrpc.py @@ -11,10 +11,9 @@ so an unrecognized request must never forward with credentials. from __future__ import annotations import json -from enum import Enum from typing import Any -from pydantic import BaseModel, ConfigDict +from onyx.sandbox_proxy.models import McpRpcClassification, McpRpcKind _TOOL_CALL_METHOD = "tools/call" @@ -40,20 +39,6 @@ _PLUMBING_METHODS = frozenset( _NOTIFICATION_PREFIX = "notifications/" -class McpRpcKind(str, Enum): - PLUMBING = "PLUMBING" # forward ungated (creds injected) - TOOL_CALL = "TOOL_CALL" # gate per tool name - UNCLASSIFIABLE = "UNCLASSIFIABLE" # fail closed — deny - - -class McpRpcClassification(BaseModel): - model_config = ConfigDict(frozen=True) - - kind: McpRpcKind - # Tool names of every `tools/call` in the (possibly batched) body, in order. - tool_names: tuple[str, ...] = () - - _PLUMBING = McpRpcClassification(kind=McpRpcKind.PLUMBING) _UNCLASSIFIABLE = McpRpcClassification(kind=McpRpcKind.UNCLASSIFIABLE) diff --git a/backend/onyx/sandbox_proxy/models.py b/backend/onyx/sandbox_proxy/models.py index 794741c6e7..d08a606f27 100644 --- a/backend/onyx/sandbox_proxy/models.py +++ b/backend/onyx/sandbox_proxy/models.py @@ -1,9 +1,13 @@ -"""Configuration models for the sandbox egress proxy.""" +"""Shared data models for the sandbox egress proxy.""" import ipaddress +from enum import Enum from pydantic import BaseModel, ConfigDict +from onyx.external_apps.matching.engine import AllMatchedActions +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox + IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network @@ -13,3 +17,48 @@ class DestinationPolicyConfig(BaseModel): api_host: str | None api_port: int | None internal_networks: tuple[IPNetwork, ...] + + +class InjectionContext(BaseModel): + """Per-request inputs every resolver receives. + + `matched_actions` is the actions matched for this request (carrying + `external_app_id`), or `None` on off-catalog forwards. `sandbox.tenant_id` is + what resolvers key their per-tenant lookups by. + """ + + model_config = ConfigDict(frozen=True) + + sandbox: ResolvedSandbox + matched_actions: AllMatchedActions | None + + +class InjectionOutcome(Enum): + PASS_THROUGH = "pass_through" + CLAIMED = "claimed" + INJECTED = "injected" + BLOCKED = "blocked" + + +class InjectionResult(BaseModel): + """Outcome of one dispatch. `block_detail` is agent-facing prose for the + 403 body, present only on BLOCKED when the resolver supplied one.""" + + model_config = ConfigDict(frozen=True) + + outcome: InjectionOutcome + block_detail: str | None = None + + +class McpRpcKind(str, Enum): + PLUMBING = "PLUMBING" # forward ungated (creds injected) + TOOL_CALL = "TOOL_CALL" # gate per tool name + UNCLASSIFIABLE = "UNCLASSIFIABLE" # fail closed — deny + + +class McpRpcClassification(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: McpRpcKind + # Tool names of every `tools/call` in the (possibly batched) body, in order. + tool_names: tuple[str, ...] = () diff --git a/backend/onyx/sandbox_proxy/request_evaluator.py b/backend/onyx/sandbox_proxy/request_evaluator.py index d7fd1c1e4d..9f0c4d866e 100644 --- a/backend/onyx/sandbox_proxy/request_evaluator.py +++ b/backend/onyx/sandbox_proxy/request_evaluator.py @@ -32,11 +32,8 @@ from onyx.external_apps.matching.engine import ( recognize_actions, ) from onyx.external_apps.matching.request import ProxiedRequest -from onyx.sandbox_proxy.mcp_jsonrpc import ( - McpRpcClassification, - McpRpcKind, - classify_mcp_request, -) +from onyx.sandbox_proxy.mcp_jsonrpc import classify_mcp_request +from onyx.sandbox_proxy.models import McpRpcClassification, McpRpcKind from onyx.sandbox_proxy.resolvers.mcp_matching import ( AmbiguousMCPTargetError, match_request, diff --git a/backend/onyx/sandbox_proxy/resolvers/external_app.py b/backend/onyx/sandbox_proxy/resolvers/external_app.py index 2f49cd8474..3d90cc35c6 100644 --- a/backend/onyx/sandbox_proxy/resolvers/external_app.py +++ b/backend/onyx/sandbox_proxy/resolvers/external_app.py @@ -17,8 +17,8 @@ from onyx.external_apps.token_refresh import ensure_fresh_credentials from onyx.sandbox_proxy.credential_injection import ( CredentialResolver, CredentialUnavailableError, - InjectionContext, ) +from onyx.sandbox_proxy.models import InjectionContext from onyx.utils.logger import setup_logger logger = setup_logger() diff --git a/backend/onyx/sandbox_proxy/resolvers/mcp_server.py b/backend/onyx/sandbox_proxy/resolvers/mcp_server.py index a90576f55d..213a241d6b 100644 --- a/backend/onyx/sandbox_proxy/resolvers/mcp_server.py +++ b/backend/onyx/sandbox_proxy/resolvers/mcp_server.py @@ -32,10 +32,10 @@ from onyx.db.users import fetch_user_by_id from onyx.sandbox_proxy.credential_injection import ( CredentialResolver, CredentialUnavailableError, - InjectionContext, ) from onyx.sandbox_proxy.logging_utils import short_log_id -from onyx.sandbox_proxy.mcp_jsonrpc import McpRpcKind, classify_mcp_request +from onyx.sandbox_proxy.mcp_jsonrpc import classify_mcp_request +from onyx.sandbox_proxy.models import InjectionContext, McpRpcKind from onyx.sandbox_proxy.resolvers.mcp_matching import ( AmbiguousMCPTargetError, CraftMCPTarget, diff --git a/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py b/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py index e7b282f273..1084483153 100644 --- a/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py +++ b/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py @@ -21,9 +21,9 @@ from onyx.db.engine.sql_engine import get_session_with_tenant from onyx.sandbox_proxy.credential_injection import ( CredentialResolver, CredentialUnavailableError, - InjectionContext, ) from onyx.sandbox_proxy.logging_utils import full_log_id, short_log_id +from onyx.sandbox_proxy.models import InjectionContext from onyx.server.features.build.configs import ONYX_SERVER_URL from onyx.server.features.build.db.sandbox import get_sandbox_by_id from onyx.utils.logger import setup_logger diff --git a/backend/onyx/sandbox_proxy/identity_docker.py b/backend/onyx/sandbox_proxy/sandbox_identity/docker.py similarity index 98% rename from backend/onyx/sandbox_proxy/identity_docker.py rename to backend/onyx/sandbox_proxy/sandbox_identity/docker.py index c4452a57bd..9213c92f45 100644 --- a/backend/onyx/sandbox_proxy/identity_docker.py +++ b/backend/onyx/sandbox_proxy/sandbox_identity/docker.py @@ -21,7 +21,8 @@ from docker.models.containers import Container from docker.types.daemon import CancellableStream from requests.exceptions import ConnectionError as RequestsConnectionError -from onyx.sandbox_proxy.identity import SandboxIdentity, SandboxIPLookup +from onyx.sandbox_proxy.sandbox_identity.models import SandboxIdentity +from onyx.sandbox_proxy.sandbox_identity.resolution import SandboxIPLookup from onyx.server.features.build.configs import ( SANDBOX_DOCKER_NETWORK, SANDBOX_DOCKER_SOCKET, diff --git a/backend/onyx/sandbox_proxy/identity_k8s.py b/backend/onyx/sandbox_proxy/sandbox_identity/kubernetes.py similarity index 98% rename from backend/onyx/sandbox_proxy/identity_k8s.py rename to backend/onyx/sandbox_proxy/sandbox_identity/kubernetes.py index e0b1200fce..97889aec5e 100644 --- a/backend/onyx/sandbox_proxy/identity_k8s.py +++ b/backend/onyx/sandbox_proxy/sandbox_identity/kubernetes.py @@ -12,7 +12,8 @@ from kubernetes import client, watch from kubernetes.client.rest import ApiException from urllib3.exceptions import ProtocolError, ReadTimeoutError -from onyx.sandbox_proxy.identity import SandboxIdentity, SandboxIPLookup +from onyx.sandbox_proxy.sandbox_identity.models import SandboxIdentity +from onyx.sandbox_proxy.sandbox_identity.resolution import SandboxIPLookup from onyx.server.features.build.configs import SANDBOX_NAMESPACE from onyx.server.features.build.sandbox.kubernetes.k8s_client import build_core_v1_api from onyx.server.features.build.sandbox.labels import ( diff --git a/backend/onyx/sandbox_proxy/sandbox_identity/models.py b/backend/onyx/sandbox_proxy/sandbox_identity/models.py new file mode 100644 index 0000000000..14079ab33a --- /dev/null +++ b/backend/onyx/sandbox_proxy/sandbox_identity/models.py @@ -0,0 +1,61 @@ +"""Sandbox identity and verified session models.""" + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class SandboxIdentity(BaseModel): + model_config = ConfigDict(frozen=True) + + sandbox_id: UUID + tenant_id: str + sandbox_name: str + sandbox_ip: str + + +class ResolvedSandbox(BaseModel): + """Sandbox identity + owning user. Authorizes egress.""" + + model_config = ConfigDict(frozen=True) + + sandbox_id: UUID + user_id: UUID + tenant_id: str + sandbox_name: str + sandbox_ip: str + + def with_session(self, session_id: UUID) -> "SessionContext": + return SessionContext( + session_id=session_id, + user_id=self.user_id, + sandbox_id=self.sandbox_id, + tenant_id=self.tenant_id, + sandbox_name=self.sandbox_name, + sandbox_ip=self.sandbox_ip, + ) + + +class SessionContext(BaseModel): + """Sandbox identity + the verified session to route the card to.""" + + model_config = ConfigDict(frozen=True) + + session_id: UUID + user_id: UUID + sandbox_id: UUID + tenant_id: str + sandbox_name: str + sandbox_ip: str + + def without_session(self) -> ResolvedSandbox: + """ + Inverse of `ResolvedSandbox.with_session(...)` — drops the session id. + """ + return ResolvedSandbox( + sandbox_id=self.sandbox_id, + user_id=self.user_id, + tenant_id=self.tenant_id, + sandbox_name=self.sandbox_name, + sandbox_ip=self.sandbox_ip, + ) diff --git a/backend/onyx/sandbox_proxy/identity.py b/backend/onyx/sandbox_proxy/sandbox_identity/resolution.py similarity index 71% rename from backend/onyx/sandbox_proxy/identity.py rename to backend/onyx/sandbox_proxy/sandbox_identity/resolution.py index f6aa5a0023..05287a9c14 100644 --- a/backend/onyx/sandbox_proxy/identity.py +++ b/backend/onyx/sandbox_proxy/sandbox_identity/resolution.py @@ -13,7 +13,6 @@ There is deliberately no most-recent-active fallback: a gated request with no verifiable session tag fails closed rather than routing to a guessed session. """ -from dataclasses import dataclass from typing import Protocol from uuid import UUID @@ -21,64 +20,13 @@ from sqlalchemy import select from onyx.db.engine.sql_engine import get_session_with_tenant from onyx.db.models import BuildSession, Sandbox +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SandboxIdentity +from onyx.server.features.build.configs import SANDBOX_BACKEND, SandboxBackend from onyx.utils.logger import setup_logger logger = setup_logger() -@dataclass(frozen=True) -class SandboxIdentity: - sandbox_id: UUID - tenant_id: str - sandbox_name: str - sandbox_ip: str - - -@dataclass(frozen=True) -class ResolvedSandbox: - """Sandbox identity + owning user. Authorizes egress.""" - - sandbox_id: UUID - user_id: UUID - tenant_id: str - sandbox_name: str - sandbox_ip: str - - def with_session(self, session_id: UUID) -> "SessionContext": - return SessionContext( - session_id=session_id, - user_id=self.user_id, - sandbox_id=self.sandbox_id, - tenant_id=self.tenant_id, - sandbox_name=self.sandbox_name, - sandbox_ip=self.sandbox_ip, - ) - - -@dataclass(frozen=True) -class SessionContext: - """Sandbox identity + the verified session to route the card to.""" - - session_id: UUID - user_id: UUID - sandbox_id: UUID - tenant_id: str - sandbox_name: str - sandbox_ip: str - - def without_session(self) -> ResolvedSandbox: - """ - Inverse of `ResolvedSandbox.with_session(...)` — drops the session id. - """ - return ResolvedSandbox( - sandbox_id=self.sandbox_id, - user_id=self.user_id, - tenant_id=self.tenant_id, - sandbox_name=self.sandbox_name, - sandbox_ip=self.sandbox_ip, - ) - - class SandboxIPLookup(Protocol): """Backend-specific IP -> SandboxIdentity resolver. @@ -148,3 +96,16 @@ class IdentityResolver: .where(BuildSession.user_id == user_id) ) return db.scalar(stmt) + + +def build_ip_lookup() -> SandboxIPLookup: + """Build the configured backend without importing the other backend SDK.""" + if SANDBOX_BACKEND is SandboxBackend.KUBERNETES: + from onyx.sandbox_proxy.sandbox_identity.kubernetes import K8sInformerLookup + + return K8sInformerLookup() + if SANDBOX_BACKEND is SandboxBackend.DOCKER: + from onyx.sandbox_proxy.sandbox_identity.docker import DockerEventsLookup + + return DockerEventsLookup() + raise RuntimeError(f"Unsupported SANDBOX_BACKEND={SANDBOX_BACKEND!r}.") diff --git a/backend/onyx/sandbox_proxy/server.py b/backend/onyx/sandbox_proxy/server.py index c574015c29..e6b535fdea 100644 --- a/backend/onyx/sandbox_proxy/server.py +++ b/backend/onyx/sandbox_proxy/server.py @@ -17,8 +17,11 @@ from onyx.cache.factory import get_cache_backend from onyx.cache.interface import CacheBackend from onyx.db.engine.sql_engine import SqlEngine from onyx.sandbox_proxy.addons.gate import GateAddon -from onyx.sandbox_proxy.backend import build_ca_store, build_ip_lookup -from onyx.sandbox_proxy.ca import CABootstrap, MaterializedCA +from onyx.sandbox_proxy.certificate_authority.bootstrap import ( + CABootstrap, + MaterializedCA, + build_ca_store, +) from onyx.sandbox_proxy.credential_injection import ( CredentialInjectionDispatcher, CredentialResolver, @@ -27,7 +30,6 @@ from onyx.sandbox_proxy.destination_policy import ( UpstreamEventLoop, parse_destination_policy, ) -from onyx.sandbox_proxy.identity import IdentityResolver, SandboxIPLookup from onyx.sandbox_proxy.models import DestinationPolicyConfig from onyx.sandbox_proxy.request_evaluator import ( CompositeRequestEvaluator, @@ -37,6 +39,11 @@ from onyx.sandbox_proxy.request_evaluator import ( from onyx.sandbox_proxy.resolvers.external_app import ExternalAppResolver from onyx.sandbox_proxy.resolvers.mcp_server import MCPServerResolver from onyx.sandbox_proxy.resolvers.onyx_pat import OnyxPatResolver +from onyx.sandbox_proxy.sandbox_identity.resolution import ( + IdentityResolver, + SandboxIPLookup, + build_ip_lookup, +) from onyx.server.features.build.configs import ( ONYX_SERVER_URL, SANDBOX_NAMESPACE, @@ -172,20 +179,18 @@ def _build_cache_factory() -> Callable[[str], CacheBackend]: return _factory -def _build_mitm_options() -> Options: - return Options( - listen_host=SANDBOX_PROXY_LISTEN_HOST, - listen_port=SANDBOX_PROXY_LISTEN_PORT, - confdir=_MITM_CONFDIR, - mode=["regular"], - ssl_insecure=False, - ssl_verify_upstream_trusted_ca=SANDBOX_PROXY_SSL_VERIFY_UPSTREAM_TRUSTED_CA, - ) - - def _build_mitm_master() -> DumpMaster: master: DumpMaster = DumpMaster( - options=_build_mitm_options(), with_termlog=False, with_dumper=False + options=Options( + listen_host=SANDBOX_PROXY_LISTEN_HOST, + listen_port=SANDBOX_PROXY_LISTEN_PORT, + confdir=_MITM_CONFDIR, + mode=["regular"], + ssl_insecure=False, + ssl_verify_upstream_trusted_ca=SANDBOX_PROXY_SSL_VERIFY_UPSTREAM_TRUSTED_CA, + ), + with_termlog=False, + with_dumper=False, ) # DumpMaster registers block_global; gate still requires pod identity. master.options.update(block_global=not SANDBOX_PROXY_ALLOW_GLOBAL_CLIENTS) diff --git a/backend/tests/external_dependency_unit/sandbox_proxy/test_gate_claim_arbiter.py b/backend/tests/external_dependency_unit/sandbox_proxy/test_gate_claim_arbiter.py index 7dab3fdd3e..e24d7ec5e3 100644 --- a/backend/tests/external_dependency_unit/sandbox_proxy/test_gate_claim_arbiter.py +++ b/backend/tests/external_dependency_unit/sandbox_proxy/test_gate_claim_arbiter.py @@ -34,8 +34,8 @@ from onyx.sandbox_proxy import approval_cache from onyx.sandbox_proxy.addons.gate import GateAddon, _IdentityResolver from onyx.sandbox_proxy.credential_injection import CredentialInjectionDispatcher from onyx.sandbox_proxy.destination_policy import parse_destination_policy -from onyx.sandbox_proxy.identity import ResolvedSandbox, SessionContext from onyx.sandbox_proxy.request_evaluator import RequestEvaluator +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SessionContext from shared_configs.contextvars import POSTGRES_DEFAULT_SCHEMA from tests.common.craft.payloads import action_entry from tests.external_dependency_unit.conftest import create_test_user diff --git a/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_docker_lookup.py b/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_docker_lookup.py index 5f767020f2..f93a258dfe 100644 --- a/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_docker_lookup.py +++ b/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_docker_lookup.py @@ -21,7 +21,7 @@ from docker import DockerClient from docker.errors import APIError, NotFound from docker.models.containers import Container -from onyx.sandbox_proxy.identity_docker import ( +from onyx.sandbox_proxy.sandbox_identity.docker import ( DockerEventsLookup, _identity_from_container, ) diff --git a/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_resolver.py b/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_resolver.py index 31b28addc0..dfa469921f 100644 --- a/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_resolver.py +++ b/backend/tests/external_dependency_unit/sandbox_proxy/test_identity_resolver.py @@ -14,7 +14,8 @@ from sqlalchemy.orm import Session from onyx.db.enums import BuildSessionStatus from onyx.db.models import BuildSession, Sandbox -from onyx.sandbox_proxy.identity import IdentityResolver, SandboxIdentity +from onyx.sandbox_proxy.sandbox_identity.models import SandboxIdentity +from onyx.sandbox_proxy.sandbox_identity.resolution import IdentityResolver from shared_configs.contextvars import POSTGRES_DEFAULT_SCHEMA from tests.external_dependency_unit.conftest import create_test_user from tests.unit.sandbox_proxy.conftest import StaticLookup diff --git a/backend/tests/external_dependency_unit/sandbox_proxy/test_mcp_server_resolver.py b/backend/tests/external_dependency_unit/sandbox_proxy/test_mcp_server_resolver.py index 281f3bda92..fe3167d478 100644 --- a/backend/tests/external_dependency_unit/sandbox_proxy/test_mcp_server_resolver.py +++ b/backend/tests/external_dependency_unit/sandbox_proxy/test_mcp_server_resolver.py @@ -42,12 +42,10 @@ from onyx.external_apps.matching.engine import ( GatedTarget, MatchedAction, ) -from onyx.sandbox_proxy.credential_injection import ( - CredentialUnavailableError, - InjectionContext, -) -from onyx.sandbox_proxy.identity import ResolvedSandbox +from onyx.sandbox_proxy.credential_injection import CredentialUnavailableError +from onyx.sandbox_proxy.models import InjectionContext from onyx.sandbox_proxy.resolvers.mcp_server import MCPServerResolver +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox from onyx.server.features.mcp.credentials import extract_connection_data from onyx.server.features.mcp.models import MCPConnectionData, MCPOAuthKeys from shared_configs.contextvars import POSTGRES_DEFAULT_SCHEMA diff --git a/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py b/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py index 88e75a68e5..4065f8ec80 100644 --- a/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py +++ b/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py @@ -14,10 +14,10 @@ import pytest from sqlalchemy.orm import Session from onyx.auth.constants import API_KEY_HEADER_ALTERNATIVE_NAME, API_KEY_HEADER_NAME -from onyx.sandbox_proxy.credential_injection import InjectionContext -from onyx.sandbox_proxy.identity import ResolvedSandbox +from onyx.sandbox_proxy.models import InjectionContext from onyx.sandbox_proxy.resolvers import onyx_pat as onyx_pat_mod from onyx.sandbox_proxy.resolvers.onyx_pat import OnyxPatResolver +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox from onyx.server.features.build.db.sandbox import ( create_sandbox__no_commit, ensure_sandbox_pat, diff --git a/backend/tests/unit/sandbox_proxy/conftest.py b/backend/tests/unit/sandbox_proxy/conftest.py index fef62c718a..afb2e68b05 100644 --- a/backend/tests/unit/sandbox_proxy/conftest.py +++ b/backend/tests/unit/sandbox_proxy/conftest.py @@ -27,13 +27,11 @@ from onyx.external_apps.matching.engine import ( MatchedAction, ) from onyx.sandbox_proxy.addons.gate import _IdentityResolver -from onyx.sandbox_proxy.credential_injection import CredentialResolver, InjectionContext -from onyx.sandbox_proxy.identity import ( - ResolvedSandbox, - SandboxIdentity, - SandboxIPLookup, -) +from onyx.sandbox_proxy.credential_injection import CredentialResolver +from onyx.sandbox_proxy.models import InjectionContext from onyx.sandbox_proxy.request_evaluator import RequestEvaluator +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SandboxIdentity +from onyx.sandbox_proxy.sandbox_identity.resolution import SandboxIPLookup _SANDBOX_ID = UUID("11111111-1111-1111-1111-111111111111") diff --git a/backend/tests/unit/sandbox_proxy/test_backend_dispatch.py b/backend/tests/unit/sandbox_proxy/test_backend_dispatch.py index 89fe21db09..9ec85039e7 100644 --- a/backend/tests/unit/sandbox_proxy/test_backend_dispatch.py +++ b/backend/tests/unit/sandbox_proxy/test_backend_dispatch.py @@ -2,7 +2,8 @@ from unittest.mock import MagicMock, patch import pytest -from onyx.sandbox_proxy import backend as backend_mod +from onyx.sandbox_proxy.certificate_authority import bootstrap as ca_mod +from onyx.sandbox_proxy.sandbox_identity import resolution as identity_mod from onyx.server.features.build.configs import SandboxBackend # Each test patches only the single constructor the dispatch branch is expected @@ -13,53 +14,60 @@ from onyx.server.features.build.configs import SandboxBackend def test_build_ca_store_kubernetes_dispatches_k8s_store() -> None: expected = MagicMock() with ( - patch("onyx.sandbox_proxy.ca_k8s.K8sSecretCAStore", return_value=expected), - patch.object(backend_mod, "SANDBOX_BACKEND", SandboxBackend.KUBERNETES), + patch( + "onyx.sandbox_proxy.certificate_authority.kubernetes.K8sSecretCAStore", + return_value=expected, + ), + patch.object(ca_mod, "SANDBOX_BACKEND", SandboxBackend.KUBERNETES), ): - assert backend_mod.build_ca_store() is expected + assert ca_mod.build_ca_store() is expected def test_build_ca_store_docker_dispatches_file_store() -> None: expected = MagicMock() with ( - patch("onyx.sandbox_proxy.ca_docker.FileCAStore", return_value=expected), - patch.object(backend_mod, "SANDBOX_BACKEND", SandboxBackend.DOCKER), + patch( + "onyx.sandbox_proxy.certificate_authority.docker.FileCAStore", + return_value=expected, + ), + patch.object(ca_mod, "SANDBOX_BACKEND", SandboxBackend.DOCKER), ): - assert backend_mod.build_ca_store() is expected + assert ca_mod.build_ca_store() is expected def test_build_ip_lookup_kubernetes_dispatches_informer() -> None: expected = MagicMock() with ( patch( - "onyx.sandbox_proxy.identity_k8s.K8sInformerLookup", return_value=expected + "onyx.sandbox_proxy.sandbox_identity.kubernetes.K8sInformerLookup", + return_value=expected, ), - patch.object(backend_mod, "SANDBOX_BACKEND", SandboxBackend.KUBERNETES), + patch.object(identity_mod, "SANDBOX_BACKEND", SandboxBackend.KUBERNETES), ): - assert backend_mod.build_ip_lookup() is expected + assert identity_mod.build_ip_lookup() is expected def test_build_ip_lookup_docker_dispatches_events_lookup() -> None: expected = MagicMock() with ( patch( - "onyx.sandbox_proxy.identity_docker.DockerEventsLookup", + "onyx.sandbox_proxy.sandbox_identity.docker.DockerEventsLookup", return_value=expected, ), - patch.object(backend_mod, "SANDBOX_BACKEND", SandboxBackend.DOCKER), + patch.object(identity_mod, "SANDBOX_BACKEND", SandboxBackend.DOCKER), ): - assert backend_mod.build_ip_lookup() is expected + assert identity_mod.build_ip_lookup() is expected def test_build_ca_store_raises_on_unknown_backend() -> None: sentinel = object() - with patch.object(backend_mod, "SANDBOX_BACKEND", sentinel): + with patch.object(ca_mod, "SANDBOX_BACKEND", sentinel): with pytest.raises(RuntimeError, match="Unsupported SANDBOX_BACKEND"): - backend_mod.build_ca_store() + ca_mod.build_ca_store() def test_build_ip_lookup_raises_on_unknown_backend() -> None: sentinel = object() - with patch.object(backend_mod, "SANDBOX_BACKEND", sentinel): + with patch.object(identity_mod, "SANDBOX_BACKEND", sentinel): with pytest.raises(RuntimeError, match="Unsupported SANDBOX_BACKEND"): - backend_mod.build_ip_lookup() + identity_mod.build_ip_lookup() diff --git a/backend/tests/unit/sandbox_proxy/test_ca_bootstrap.py b/backend/tests/unit/sandbox_proxy/test_ca_bootstrap.py index 01eeccd7e6..461c57bb9b 100644 --- a/backend/tests/unit/sandbox_proxy/test_ca_bootstrap.py +++ b/backend/tests/unit/sandbox_proxy/test_ca_bootstrap.py @@ -8,7 +8,11 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID -from onyx.sandbox_proxy.ca import CABootstrap, CAStore, CAStoreConflictError +from onyx.sandbox_proxy.certificate_authority.bootstrap import ( + CABootstrap, + CAStore, + CAStoreConflictError, +) class _InMemoryStore(CAStore): diff --git a/backend/tests/unit/sandbox_proxy/test_ca_docker.py b/backend/tests/unit/sandbox_proxy/test_ca_docker.py index 0836485a6c..45971c1d33 100644 --- a/backend/tests/unit/sandbox_proxy/test_ca_docker.py +++ b/backend/tests/unit/sandbox_proxy/test_ca_docker.py @@ -2,8 +2,8 @@ from pathlib import Path import pytest -from onyx.sandbox_proxy.ca import CAStoreConflictError -from onyx.sandbox_proxy.ca_docker import FileCAStore +from onyx.sandbox_proxy.certificate_authority.bootstrap import CAStoreConflictError +from onyx.sandbox_proxy.certificate_authority.docker import FileCAStore _CERT = b"-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----\n" _KEY = b"-----BEGIN PRIVATE KEY-----\nfake-key\n-----END PRIVATE KEY-----\n" diff --git a/backend/tests/unit/sandbox_proxy/test_ca_k8s.py b/backend/tests/unit/sandbox_proxy/test_ca_k8s.py index 269f293340..8fe9e84723 100644 --- a/backend/tests/unit/sandbox_proxy/test_ca_k8s.py +++ b/backend/tests/unit/sandbox_proxy/test_ca_k8s.py @@ -5,8 +5,8 @@ import pytest from kubernetes import client from kubernetes.client.rest import ApiException -from onyx.sandbox_proxy.ca import CAStoreConflictError -from onyx.sandbox_proxy.ca_k8s import K8sSecretCAStore +from onyx.sandbox_proxy.certificate_authority.bootstrap import CAStoreConflictError +from onyx.sandbox_proxy.certificate_authority.kubernetes import K8sSecretCAStore def _api_exception(status: int) -> ApiException: @@ -139,7 +139,9 @@ def test_ensure_configmap_replaces_on_409(monkeypatch: pytest.MonkeyPatch) -> No core_api = MagicMock(spec=client.CoreV1Api) core_api.create_namespaced_config_map.side_effect = _api_exception(409) core_api.replace_namespaced_config_map.return_value = None - monkeypatch.setattr("onyx.sandbox_proxy.ca_k8s.time.sleep", lambda _: None) + monkeypatch.setattr( + "onyx.sandbox_proxy.certificate_authority.kubernetes.time.sleep", lambda _: None + ) _make_store(core_api)._ensure_configmap( b"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" @@ -166,7 +168,9 @@ def test_ensure_configmap_retries_on_repeated_409( _api_exception(409), None, ] - monkeypatch.setattr("onyx.sandbox_proxy.ca_k8s.time.sleep", lambda _: None) + monkeypatch.setattr( + "onyx.sandbox_proxy.certificate_authority.kubernetes.time.sleep", lambda _: None + ) _make_store(core_api)._ensure_configmap( b"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" diff --git a/backend/tests/unit/sandbox_proxy/test_credential_injection.py b/backend/tests/unit/sandbox_proxy/test_credential_injection.py index b2689adc6b..ccb49ef2c2 100644 --- a/backend/tests/unit/sandbox_proxy/test_credential_injection.py +++ b/backend/tests/unit/sandbox_proxy/test_credential_injection.py @@ -13,9 +13,8 @@ from onyx.sandbox_proxy.credential_injection import ( CredentialInjectionDispatcher, CredentialResolver, CredentialUnavailableError, - InjectionContext, - InjectionOutcome, ) +from onyx.sandbox_proxy.models import InjectionContext, InjectionOutcome from tests.unit.sandbox_proxy.conftest import ( RecordingCredentialResolver, make_flow, diff --git a/backend/tests/unit/sandbox_proxy/test_external_app_resolver.py b/backend/tests/unit/sandbox_proxy/test_external_app_resolver.py index 62f73b8ced..a43998df31 100644 --- a/backend/tests/unit/sandbox_proxy/test_external_app_resolver.py +++ b/backend/tests/unit/sandbox_proxy/test_external_app_resolver.py @@ -16,10 +16,8 @@ from unittest.mock import MagicMock import pytest from onyx.external_apps.matching.engine import AllMatchedActions -from onyx.sandbox_proxy.credential_injection import ( - CredentialUnavailableError, - InjectionContext, -) +from onyx.sandbox_proxy.credential_injection import CredentialUnavailableError +from onyx.sandbox_proxy.models import InjectionContext from onyx.sandbox_proxy.resolvers import external_app from onyx.sandbox_proxy.resolvers.external_app import ExternalAppResolver from tests.unit.sandbox_proxy.conftest import ( diff --git a/backend/tests/unit/sandbox_proxy/test_gate.py b/backend/tests/unit/sandbox_proxy/test_gate.py index ace0e36bf2..d43c285de1 100644 --- a/backend/tests/unit/sandbox_proxy/test_gate.py +++ b/backend/tests/unit/sandbox_proxy/test_gate.py @@ -46,15 +46,12 @@ from onyx.sandbox_proxy.credential_injection import ( CredentialInjectionDispatcher, CredentialResolver, CredentialUnavailableError, - InjectionOutcome, -) -from onyx.sandbox_proxy.destination_policy import ( - parse_destination_policy, ) +from onyx.sandbox_proxy.destination_policy import parse_destination_policy from onyx.sandbox_proxy.errors import SandboxProxyError -from onyx.sandbox_proxy.identity import ResolvedSandbox, SessionContext -from onyx.sandbox_proxy.models import DestinationPolicyConfig +from onyx.sandbox_proxy.models import DestinationPolicyConfig, InjectionOutcome from onyx.sandbox_proxy.request_evaluator import RequestEvaluator +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SessionContext from tests.unit.sandbox_proxy.conftest import ( RecordingCredentialResolver, StubResolver, diff --git a/backend/tests/unit/sandbox_proxy/test_healthz.py b/backend/tests/unit/sandbox_proxy/test_healthz.py index d133a2abd3..8dd1f4598f 100644 --- a/backend/tests/unit/sandbox_proxy/test_healthz.py +++ b/backend/tests/unit/sandbox_proxy/test_healthz.py @@ -7,7 +7,7 @@ from http.server import HTTPServer import pytest from onyx.sandbox_proxy import server as proxy_server -from onyx.sandbox_proxy.identity import SandboxIPLookup +from onyx.sandbox_proxy.sandbox_identity.resolution import SandboxIPLookup from onyx.sandbox_proxy.server import _build_healthz_handler, _Readiness diff --git a/backend/tests/unit/sandbox_proxy/test_identity.py b/backend/tests/unit/sandbox_proxy/test_identity.py index b89db5c564..29548805d9 100644 --- a/backend/tests/unit/sandbox_proxy/test_identity.py +++ b/backend/tests/unit/sandbox_proxy/test_identity.py @@ -5,12 +5,9 @@ from uuid import UUID, uuid4 import pytest -from onyx.sandbox_proxy import identity as identity_mod -from onyx.sandbox_proxy.identity import ( - IdentityResolver, - ResolvedSandbox, - SandboxIdentity, -) +from onyx.sandbox_proxy.sandbox_identity import resolution as identity_mod +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox, SandboxIdentity +from onyx.sandbox_proxy.sandbox_identity.resolution import IdentityResolver from tests.unit.sandbox_proxy.conftest import StaticLookup diff --git a/backend/tests/unit/sandbox_proxy/test_identity_docker.py b/backend/tests/unit/sandbox_proxy/test_identity_docker.py index ae057ba55c..c7c5d12592 100644 --- a/backend/tests/unit/sandbox_proxy/test_identity_docker.py +++ b/backend/tests/unit/sandbox_proxy/test_identity_docker.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock import pytest from docker import DockerClient -from onyx.sandbox_proxy.identity_docker import ( +from onyx.sandbox_proxy.sandbox_identity.docker import ( DockerEventsLookup, _identity_from_container, ) diff --git a/backend/tests/unit/sandbox_proxy/test_informer.py b/backend/tests/unit/sandbox_proxy/test_informer.py index dceef3f395..b1aac512e8 100644 --- a/backend/tests/unit/sandbox_proxy/test_informer.py +++ b/backend/tests/unit/sandbox_proxy/test_informer.py @@ -3,7 +3,10 @@ from unittest.mock import MagicMock, patch import pytest from kubernetes import client -from onyx.sandbox_proxy.identity_k8s import K8sInformerLookup, _identity_from_pod +from onyx.sandbox_proxy.sandbox_identity.kubernetes import ( + K8sInformerLookup, + _identity_from_pod, +) def _make_pod( @@ -148,7 +151,9 @@ def test_synced_clears_after_watch_loop_returns_cleanly() -> None: def stop(self) -> None: pass - with patch("onyx.sandbox_proxy.identity_k8s.watch.Watch", _StubWatch): + with patch( + "onyx.sandbox_proxy.sandbox_identity.kubernetes.watch.Watch", _StubWatch + ): lookup._run() assert lookup._initial_sync_done.is_set() diff --git a/backend/tests/unit/sandbox_proxy/test_mcp_jsonrpc.py b/backend/tests/unit/sandbox_proxy/test_mcp_jsonrpc.py index e95560fa63..b1350719ab 100644 --- a/backend/tests/unit/sandbox_proxy/test_mcp_jsonrpc.py +++ b/backend/tests/unit/sandbox_proxy/test_mcp_jsonrpc.py @@ -12,7 +12,8 @@ from typing import Any import pytest -from onyx.sandbox_proxy.mcp_jsonrpc import McpRpcKind, classify_mcp_request +from onyx.sandbox_proxy.mcp_jsonrpc import classify_mcp_request +from onyx.sandbox_proxy.models import McpRpcKind def _body(payload: Any) -> bytes: diff --git a/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py b/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py index 7dae26a034..031dc65bfe 100644 --- a/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py +++ b/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py @@ -16,10 +16,8 @@ from unittest.mock import MagicMock import pytest from onyx.auth.constants import API_KEY_HEADER_ALTERNATIVE_NAME, API_KEY_HEADER_NAME -from onyx.sandbox_proxy.credential_injection import ( - CredentialUnavailableError, - InjectionContext, -) +from onyx.sandbox_proxy.credential_injection import CredentialUnavailableError +from onyx.sandbox_proxy.models import InjectionContext from onyx.sandbox_proxy.resolvers import onyx_pat from onyx.sandbox_proxy.resolvers.onyx_pat import OnyxPatResolver from tests.unit.sandbox_proxy.conftest import make_flow, make_resolved_sandbox diff --git a/backend/tests/unit/sandbox_proxy/test_response_streaming.py b/backend/tests/unit/sandbox_proxy/test_response_streaming.py index dd8ad4a6de..448f4d3924 100644 --- a/backend/tests/unit/sandbox_proxy/test_response_streaming.py +++ b/backend/tests/unit/sandbox_proxy/test_response_streaming.py @@ -36,8 +36,8 @@ from onyx.sandbox_proxy.destination_policy import ( UpstreamEventLoop, parse_destination_policy, ) -from onyx.sandbox_proxy.identity import ResolvedSandbox from onyx.sandbox_proxy.request_evaluator import RequestEvaluator +from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox # Inter-chunk delay on the upstream. The whole stream takes # `(_CHUNK_COUNT - 1) * _CHUNK_DELAY_S`, during which a streamed client must diff --git a/backend/tests/unit/sandbox_proxy/test_server.py b/backend/tests/unit/sandbox_proxy/test_server.py index df41299093..bc13a65ba1 100644 --- a/backend/tests/unit/sandbox_proxy/test_server.py +++ b/backend/tests/unit/sandbox_proxy/test_server.py @@ -2,9 +2,11 @@ import asyncio import tempfile +from unittest.mock import patch import pytest from mitmproxy import http +from mitmproxy.options import Options from mitmproxy.tools.dump import DumpMaster from onyx.sandbox_proxy import server @@ -22,7 +24,9 @@ def test_mitm_options_use_custom_upstream_ca_when_configured( "/var/run/sandbox-proxy/upstream-ca-bundle.crt", ) - options = server._build_mitm_options() + with patch.object(server, "DumpMaster") as build_master: + server._build_mitm_master() + options: Options = build_master.call_args.kwargs["options"] assert ( options.ssl_verify_upstream_trusted_ca @@ -36,7 +40,9 @@ def test_mitm_options_keep_default_trust_store_without_custom_ca( ) -> None: monkeypatch.setattr(server, "SANDBOX_PROXY_SSL_VERIFY_UPSTREAM_TRUSTED_CA", None) - options = server._build_mitm_options() + with patch.object(server, "DumpMaster") as build_master: + server._build_mitm_master() + options: Options = build_master.call_args.kwargs["options"] assert options.ssl_verify_upstream_trusted_ca is None assert options.ssl_insecure is False diff --git a/docs/craft/dev/local-compose-craft.md b/docs/craft/dev/local-compose-craft.md index 94cfe04022..2d8fc91eb9 100644 --- a/docs/craft/dev/local-compose-craft.md +++ b/docs/craft/dev/local-compose-craft.md @@ -149,7 +149,7 @@ Iterate on `sandbox_proxy/` code with the VSCode debugger attached. 4. **Provision a sandbox** via the API as you normally would, and trigger a gated action (e.g. a Slack `chat.postMessage`). Set - breakpoints in `gate.py`, `addons/gate.py`, `identity_docker.py`, + breakpoints in `gate.py`, `addons/gate.py`, `sandbox_identity/docker.py`, etc. ## Smoke-check commands diff --git a/docs/craft/features/egress-proxy-and-approvals/README.md b/docs/craft/features/egress-proxy-and-approvals/README.md index 03dc6c9d5e..47ee94305a 100644 --- a/docs/craft/features/egress-proxy-and-approvals/README.md +++ b/docs/craft/features/egress-proxy-and-approvals/README.md @@ -20,15 +20,16 @@ The short version: Proxy runtime: +- `backend/onyx/sandbox_proxy/models.py` defines shared destination, credential + injection, and MCP classification models. - `backend/onyx/sandbox_proxy/server.py` starts mitmproxy, health checks, CA bootstrap, identity lookup, request evaluator, credential resolvers, and the gate addon. - `backend/onyx/sandbox_proxy/addons/gate.py` owns request classification, approval parking, grant resolution, credential injection dispatch, internal destination blocking, and SIGTERM drain cleanup. -- `backend/onyx/sandbox_proxy/identity.py`, - `identity_k8s.py`, and `identity_docker.py` resolve source IPs to sandbox, - tenant, and user identity. +- `backend/onyx/sandbox_proxy/sandbox_identity/` modules resolve source IPs to sandbox, + tenant, and user identity. Its `models.py` defines the identity and session models. - `backend/onyx/sandbox_proxy/request_evaluator.py` turns mitmproxy requests into external-app action matches. - `backend/onyx/sandbox_proxy/credential_injection.py` and @@ -36,7 +37,7 @@ Proxy runtime: and external-app credentials. - `backend/onyx/sandbox_proxy/approval_cache.py` defines Redis announce, wake, and session-grant cache keys. -- `backend/onyx/sandbox_proxy/ca.py`, `ca_k8s.py`, and `ca_docker.py` manage +- `backend/onyx/sandbox_proxy/certificate_authority/` modules manage the proxy CA. Approval persistence and API: diff --git a/docs/craft/features/scheduled-tasks/pre-approvals.md b/docs/craft/features/scheduled-tasks/pre-approvals.md index 392ef5c323..ddf5790162 100644 --- a/docs/craft/features/scheduled-tasks/pre-approvals.md +++ b/docs/craft/features/scheduled-tasks/pre-approvals.md @@ -43,7 +43,7 @@ Constraints from the existing code that shape the design: actions with a stored `external_app_policy` row reach the matcher at all; "gated" means a stored row with `ASK`. - **`SessionContext` does not carry `origin`** — - `resolve_session_by_id` (`sandbox_proxy/identity.py`) selects only + `resolve_session_by_id` (`sandbox_proxy/sandbox_identity/resolution.py`) selects only `BuildSession.id`. The short-circuit needs one new joined lookup: `BuildSession → ScheduledTaskRun (session_id FK) → ScheduledTask`; grants come along with the task row.