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.
Where each layer applies
Section titled “Where each layer applies”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"]The pod baseline
Section titled “The pod baseline”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.
What a sandbox can reach
Section titled “What a sandbox can reach”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.
Stronger isolation: gVisor and Kata
Section titled “Stronger isolation: gVisor and Kata”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.
A tool allowlist is not a boundary
Section titled “A tool allowlist is not a boundary”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.
Per-call policy
Section titled “Per-call policy”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.
Credentials never pass through tools
Section titled “Credentials never pass through tools”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.
Mount authorization
Section titled “Mount authorization”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.
Only the operator writes a mounting claim
Section titled “Only the operator writes a mounting claim”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.
Declaring sandboxes at session open
Section titled “Declaring sandboxes at session open”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.
Related pages
Section titled “Related pages”- Codegen sandbox isolation: the codegen workspace, cache and promotion
SandboxClaim: the claim, the two class kinds and the class’s pod shapeToolPolicy: producers and Cedar policies- Policy engine architecture: how the policy-broker decides each call