Skip to content

Sandbox security

A sandbox is a pod that does what a model tells it to: run shell commands in a codegen sandbox, or drive a web page in a browser sandbox. Whoever may write the SandboxClass chooses the image, so Omnia treats the image as untrusted and contains every sandbox in layers that hold whatever the image does: the pod baseline, the tools a ToolRegistry offers, per-call policy, and credential handling.

Codegen sandbox isolation covers what is specific to codegen, and the life of a claim.

The diagram follows one tool call: the policy check, the claim that provides the pod, and the network paths in and out of it.

flowchart LR
rt["Agent pod<br/>runtime"] -- "POST /v1/decision" --> pb["policy-broker<br/>ToolPolicy"]
rt -- "POST /v1/sandboxes/acquire" --> api["Operator<br/>Sandbox API"]
reg["ToolRegistry<br/>spec.sandboxes"] -- "class, targetPath" --> api
api -- "creates" --> claim["SandboxClaim"]
claim -- "controller builds" --> pod["Sandbox pod<br/>Deployment, Service,<br/>NetworkPolicy"]
rt -- "MCP to status.addresses" --> pod
pod -- "HTTPS_PROXY" --> proxy["Egress proxy<br/>one per claim"]
proxy -- "443, bound hosts" --> ext["Connection hosts"]

Every sandbox pod is built the same way, whatever its class. It mounts no ServiceAccount token, so a process in the pod has no credential for the API server. It applies the restricted profile: a non-root uid, seccomp RuntimeDefault, every capability dropped and privilege escalation off. The CRD refuses a class without cpu and memory limits, because code that loops or forks without one takes down the node. trusted: true lifts that one requirement for a class that runs first-party code and changes nothing else. No class field relaxes it.

A sandbox is its own Deployment, never a sidecar in the agent’s pod. A sidecar shares the agent’s network namespace, and the agent must reach its LLM provider, so the sandbox would inherit that egress.

The sandbox’s NetworkPolicy declares Egress in its policyTypes and carries no egress rules unless the class adds some. Kubernetes reads that as deny everything: in-cluster Services, the API server, the internet, and DNS with them.

A class may name in-cluster Services in egress.allowedServices, such as an artifact mirror you run. The operator builds the rule from that Service’s own pod selector and target ports, and a misconfigured entry produces no rule. The path holds against a process with a shell because the pod cannot rewrite a NetworkPolicy. What a named Service lets through is yours to decide.

A sandbox reaches an external service only through a Connection bound to it. The operator runs a per-claim egress proxy that tunnels HTTPS to the bound Connections’ hosts, refuses any host that resolves to a private address, and has no API access. The sandbox’s NetworkPolicy opens a path to that proxy, so a process that ignores HTTPS_PROXY reaches nothing (Egress).

DNS stays off unless the class sets egress.dns, because a process that can query the cluster resolver can encode data into names. Ingress admits only pods labelled both app.kubernetes.io/name=omnia-agent and app.kubernetes.io/instance=<runtime>, because the name label alone would let any agent in the namespace call this sandbox’s tools.

The baseline runs against the host kernel, so a kernel vulnerability bypasses it. SandboxClass.spec.runtimeClassName names a RuntimeClass: gVisor, which puts its own kernel between the workload and the host’s, or Kata, which runs the pod in a lightweight VM. Use one wherever your nodes support it; starts and syscall-heavy work are slower.

A browser sandbox gains the most: it runs Chromium with --no-sandbox, because the Restricted Pod Security Standard denies Chromium the user namespaces its own sandbox needs, so the pod is all that contains the renderer.

The class is a backend; the registry chooses the tools

Section titled “The class is a backend; the registry chooses the tools”

A class says how to run an image and which ports it serves. A ToolRegistry sandbox names the class, each handler names the port it dials, and an MCP handler’s toolFilter.allowlist decides which tools the runtime offers the model. The ToolRegistry reports a handler on an undeclared port as SandboxesValid=False.

CI, and the publish workflow before it tags the pushed digest, run hack/browser-sandbox-image-check.sh on the browser image. It asserts that the image has no shell, that its executables are exactly the four it means to ship, and that the live tools/list equals the bridge’s allowlist.

An allowlist decides which of a sandbox’s tools the runtime offers the model. It removes nothing from the sandbox: the MCP server registers every tool it has, and the NetworkPolicy’s ingress rule stops anything other than the owning runtime calling them. In a codegen sandbox it bounds even less, because a model that can run Bash can do anything the image can, so leaving Read off the allowlist does not stop it reading a file.

A sandbox tool call goes through the same runtime executor as any server tool, so with Enterprise a ToolPolicy can allow or deny each call before it reaches the sandbox.

A browser’s navigation target is a structured argument: agent_browser_open, agent_browser_read and agent_browser_tab_new each take a url. A CEL producer can derive the host from body.url, and a Cedar permit can require that host to be in a list, so each navigation is judged on where it goes. The selector must cover every tool that takes a url, or the model navigates through the one left out. Beneath the policy, the bridge refuses any url that is not http or https with a host, or about:blank.

A policy judges what the model asks for, not what the page does next: a redirect or a clicked link carries no url argument, and the pod’s egress bounds those. Codegen’s Bash takes one string, so a policy can allow or deny the tool as a whole and nothing finer.

The browser bridge can run with a signed-in session: a site’s cookies and localStorage, as a storage-state file. A session is a credential, so the bridge keeps it away from the model. It loads the session into the browser itself, before the first tool call, and then deletes the file. No tool loads, saves or shows a session: agent-browser’s state_* and cookies_* tools are never advertised or forwarded, and the session arguments every upstream tool accepts are refused. Every result is scrubbed: each stored cookie and localStorage value of 6 or more characters becomes [redacted], in the forms a page is likely to render it (URL-encoded, HTML- and JSON-escaped, base64, each segment of a JWT). Matching ignores case, a session cookie the site rotates is scrubbed too, and structured output is dropped.

Screenshots are refused unless you start the bridge with -allow-screenshots, because an image of an authenticated page cannot be redacted. agent_browser_close and agent_browser_window_new are refused, because either would discard a session that cannot be loaded again. When the browser reaches one of the site’s signed-out URLs, checked before and after every call, the bridge answers connection_reauth_required and refuses every later call. It never signs back in: from inside the pod, an expired session and a deliberate logout look the same.

The chart’s browser class runs an anonymous browser until a browserSession Connection is bound to a role that declares a connections[].session.

Scrubbing is defence in depth. It misses a value in a form it does not predict, localStorage written after the session loads, values under 6 characters, a login inside an iframe, and other personal data a page shows. The model can also confirm a guessed value by watching for [redacted].

What bounds a signed-in session is egress. A sandbox holding a site’s credential reaches that site’s hosts and nothing else, so a value the model does see has nowhere else to go. The chart’s browser class names no egress, so its pod reaches only the hosts of the Connections bound to it. It mounts /tmp in memory, so the cookies Chromium writes never reach the node’s disk.

A codegen sandbox’s /workspace is its own scratch directory unless its claim mounts a directory of the workspace’s content that a mount role names, read-write. That hands the model write access to work a person owns, so the operator decides it for the person the agent is acting for, never for the agent. A role’s path can reach only the workspace’s own content, never the directories other claims and components own. See mount roles for the fields and the reserved directories.

The runtime may declare a mount when it acquires a sandbox, but it cannot say who the person is. The user comes only from a delegation token the operator signed, on the X-Omnia-Delegation header, minted for the Sandbox API, this conversation, this agent and its Workspace. The user’s workspace role reaches that token by one route.

flowchart LR
dash["Dashboard<br/>computes the role"] -- "mgmt-plane JWT<br/>obo, wrole, workspace" --> facade["Facade"]
facade -- "subject token" --> mint["Delegation mint<br/>in the operator"]
mint -- "session token" --> facade
facade -- "session token as subject proof" --> runtime["Runtime"]
runtime -- "session token" --> mint
mint -- "delegation token<br/>aud omnia/sandbox-api" --> runtime
runtime -- "acquire + X-Omnia-Delegation" --> api["Sandbox API<br/>mount authorizer"]

The dashboard signs the role beside the user (obo) into its management-plane token. The mint verifies that token against the dashboard’s keys and copies the role forward only while the user is one the operator verified and the role was computed for the workspace the calling pod runs in.

Each hop is bound to the conversation (sid) and the agent (act.sub), and no token outlives the one it came from. The runtime therefore cannot spend the user’s role on another conversation, hand it to another agent, or renew it past the dashboard token’s expiry. The authorizer requires a token minted in that Workspace with at least the role’s minRole, and the operator records the resolved directory on the claim, so changing the role later never moves what an existing claim binds.

Anyone with RBAC on sandboxclaims could write a claim naming any directory, so the operator honours a configured or session mount, or a Connection binding, only on a claim it wrote. The proof comes from the API server, not from the claim: the chart installs a ValidatingAdmissionPolicy that refuses to create such a claim, or to change its spec, for every identity but the operator’s ServiceAccount. A caller cannot write an annotation or label that passes for that identity.

The operator checks that the policy was in force when the claim was created. Anything else fails the claim MountNotGranted (or ConnectionsNotGranted), and a cluster without ValidatingAdmissionPolicy gets scratch sandboxes only. The trust root is whoever may edit cluster-scoped admission policy or act as the operator. The same check guards reuse: see declared mounts.

A client asks for a configured mount, such as a project, by declaring it on the WebSocket message that opens the session. The facade forwards the bytes to the runtime unread. Before it serves the message, the runtime mints a delegation token and asks the Sandbox API for each declared sandbox, without waiting for it to bind. The authorizer decides the mount, and a refusal fails the open. The WebSocket protocol lists the declare field and the refusal reasons.