2026-07-16

AI Agent Sandbox Security: gVisor Needs an 18-Control Admission Contract

A deterministic admission probe shows why selecting runsc is not enough: bind runtime, image, identity, mounts, network, limits, lifecycle, and evidence before execution.

AI Agent Sandbox Security: gVisor Needs an 18-Control Admission Contract cover illustration

A launcher can print runsc, start a container, and still leave the dangerous decisions open. The model may be able to request host networking. A mutable image tag may replace the image you reviewed. The Docker socket may be mounted because one integration “needs it.” If runsc disappears, an eager scheduler may quietly fall back to runc.

That is why I would not approve an AI agent sandbox on the runtime name alone. gVisor changes the kernel boundary, but the launcher still owns identity, mounts, network, resource ceilings, secrets, expiry, and evidence. The security contract has to bind all of them before untrusted work starts.

To make that distinction measurable, I ran a deterministic admission probe over two OCI-like launch specifications. The convenient demo profile passed 0 of 18 controls. A fail-closed runsc profile passed 18 of 18. I then applied ten hostile policy mutations; the evaluator rejected all ten. The fixture SHA-256 is c832aca74888e91a408bc71b6dc5a2a9493bb7ddf2e0c5e72f3241f56ba4b908.

This is configuration evidence, not a live escape test. The workspace had neither Docker nor runsc, so no sandbox was launched and no kernel-enforcement claim is implied.

gVisor narrows the System API; it does not finish the sandbox

Ordinary containers isolate processes while continuing to use the host kernel for allowed system calls. gVisor inserts its Sentry between the application and the host. The Sentry implements the application-facing Linux System API in user space and exposes a smaller host-facing surface. It deliberately avoids simply passing application system calls through to the host.

That is a useful boundary for untrusted agent tools, especially when the team wants an OCI workflow without assigning a microVM to every command. It is not a magic shell. gVisor's own security guide says resource exhaustion depends on host cgroups and network access depends on container network policy. It also excludes hardware side channels from its threat model and warns that compatibility and performance can differ from native containers.

The operational conclusion is slightly unfashionable: selecting runsc is the start of the review, not its outcome.

Concentric isolation boundaries surrounding an untrusted workload and its compute resources
The runtime boundary matters, but identity, storage, resources, network policy, and evidence must close around it.

The 18-control contract is intentionally boring

The governed fixture admits one offline task profile. It does not let the prompt choose a runtime or negotiate a looser profile. Every field below is supplied by trusted policy before the command reaches the queue.

BoundaryAdmission requirementWhy the launcher owns it
Runtimerunsc, a recorded runtime binary digest, and no fallbackThe workload cannot attest its own jail
ImageRegistry reference pinned by SHA-256 digestA tag can move after review
IdentityNumeric non-root UID/GID, not privileged, all capabilities dropped, no new privilegesPrompt intent is not an authorization primitive
FilesystemRead-only root; only task-scoped /workspace and bounded /tmp writableA read-only host mount can still leak data
System callsRuntime-default seccomp remains enabledDisabling a second boundary should be a policy change, not a task option
Networknone, with no operator-requested overrideOffline work should not inherit ambient internet access
ResourcesCPU, memory, swap, PIDs, wall time, stdout, file size, and artifact count are finitegVisor does not replace host cgroups or an external supervisor
SecretsNo ambient secret environment; brokered operations onlyContainment does not make credentials disposable
LifecycleTask ID, expiry, and crash reconciler are mandatoryAbandoned containers and volumes are still exposure
EvidencePolicy version, runtime and image digests, denial reason, and artifact hashes“It used gVisor” is too vague for incident review

The demo profile left every one of those decisions weak or implicit. It used runc, a latest tag, root identity, a writable root, SYS_ADMIN, host networking, the Docker socket, the host root, unconfined seccomp, ambient secrets, unlimited resources, and no expiry or evidence contract. That profile failed all 18 checks. This was not a benchmark contest; the difference came from policy completeness.

Host ceilings are part of the contract

The Sentry does not own that budget.

CPU shares, memory and swap limits, PID ceilings, and OOM behavior remain host responsibilities. Wall time, stdout volume, individual file size, and artifact count belong to a supervisor outside the sandbox. If a task expires, that supervisor terminates it, records the reason, and lets a reconciler remove abandoned containers and volumes after a crash. A policy that names gVisor but leaves those values unlimited still gives hostile code an inexpensive denial-of-service path.

Runtime selection belongs in a trusted launcher

The model should ask for a task class such as python-offline-small. A trusted launcher maps that class to an approved image digest, runsc runtime identity, fixed resource envelope, and network profile. If any binding is missing or stale, admission stops. It must not reinterpret failure as permission to use the host's default runtime.

request.taskClass = "python-offline-small"

policy.resolve(request.taskClass) -> {
  runtime: "runsc@sha256:...",
  image: "agent-runner@sha256:...",
  uid: 10001,
  rootfs: "read-only",
  writable: ["task-volume:/workspace", "tmpfs:/tmp"],
  network: "none",
  limits: { cpu: 1, memoryMiB: 768, pids: 128, wallSeconds: 120 }
}

if any binding cannot be verified:
  deny; record exact reason; do not fall back

Do not use dmesg output from inside the container as runtime attestation. The gVisor Docker quick start explicitly notes that this output may be spoofed. Evidence should come from the trusted launcher and runtime metadata, outside the workload's control.

Failure modes in a clever mount graph

The governed profile permits a task volume at /workspace and a bounded temporary filesystem at /tmp. The root filesystem remains read-only. There is no Docker socket, host root, home directory, SSH directory, dependency cache shared across tenants, or cloud credential directory.

That last point is easy to underestimate. Marking a bind mount read-only prevents modification; it does not prevent disclosure. An agent that can read a host home directory can copy tokens without ever escaping its sandbox. Copy the minimum inputs into a new task volume, validate outputs, hash approved artifacts, then destroy the volume through the reconciler.

Rootless Docker can reduce daemon and container privilege on the host, and Docker's default seccomp profile blocks a set of high-risk system calls. Both are useful defense-in-depth. Neither justifies a privileged workload or a broad host mount.

Offline should be a real profile, not a suggestion

gVisor has its own userspace network stack. Its networking guide also makes the cost of shortcuts clear: host networking reduces isolation, while --network=none disables external networking and keeps loopback inside the sandbox. For a code transformation, parser, formatter, or fixed-input analysis task, that is the right default.

Tasks that truly need a package registry or API should move to a different reviewed profile. Put the egress broker outside the sandbox, deny by default, block private and link-local destinations, bind destinations to policy rather than prompt text, and cap bytes and time. gVisor can apply traffic shaping, but rate control is not destination authorization.

An isolated workload sending one narrow approved route through a guarded egress boundary while other routes are blocked
Network capability should be a named profile with a narrow gate, never an ambient property of the container.

Testing ten requests before container creation

I cloned the governed specification and applied one hostile mutation at a time. The evaluator denied all ten. Each denial mapped to a named failed control rather than a vague “unsafe” verdict.

Requested mutationDecisionControl that caught it
Use host networkingDenyOffline network is not overridable
Mount /var/run/docker.sockDenyNo sensitive host mounts
Mount host /, even read-onlyDenyNo sensitive host mounts
Run as UID 0DenyNumeric non-root identity
Add SYS_ADMINDenyAll capabilities dropped
Replace the digest with latestDenyImage is digest-pinned
Disable seccompDenySeccomp is enforced
Remove the PID limitDenyPID count has a host ceiling
Switch the offline profile to bridge egressDenyOffline network is not overridable
Allow fallback when runsc is unavailableDenyRuntime cannot fall back

Admission ends before scheduling begins

This is where admission testing earns its place. None of these requests needs a live container to evaluate. Rejecting them earlier is cheaper, easier to explain, and prevents the scheduler from creating a dangerous intermediate state.

The evaluator should return a stable denial class, the failed control names, the requested task class, and the reviewed policy version. It should not echo raw prompts, credentials, or complete environment values. That record is enough to distinguish a malformed request from policy drift without turning the audit log into a second secret store.

What the probe did not prove

The exact fixture and per-control findings are retained, but the result has a narrow meaning. It proves that one declared specification satisfies 18 static controls and that ten declared mutations violate at least one control. It does not prove that a host applied those settings.

No untrusted binary ran. I did not attempt a filesystem escape, raw socket, fork bomb, memory exhaustion, timeout, cleanup, checkpoint or restore, compatibility test, latency measurement, throughput benchmark, or hardware side-channel test. Docker and runsc were absent from the workspace host. A production rollout still needs live negative tests on the actual kernel, runtime build, cgroup hierarchy, network path, image, supervisor, and reconciler.

Those live tests should retain exit codes, redacted error classes, peak resource measurements, exact runtime and image digests, task expiry, cleanup state, and artifact hashes. They should never rely on output supplied by the untrusted process as proof of its own containment.

Where I would promote this design—and where I would not

I would use the governed profile as the review contract for short-lived, non-privileged Linux tools with bounded inputs and outputs: code formatting, document parsing, fixed-repository analysis, and similar jobs. I would canary it on a dedicated worker pool, run live denial fixtures after every runtime or kernel update, and stop scheduling if the runtime identity or reconciler becomes unhealthy.

Compatibility failures belong in another pool

I would not force gVisor onto workloads that require kernel modules, direct devices, privileged containers, nested virtualization, or unusual system-call behavior. Those tasks need a separately governed microVM or dedicated-host pool, or they need to be rejected. Compatibility pressure is not a reason to weaken the default profile silently.

Fallback is the failure mode.

The promotion rule is simple: a successful command is necessary but uninteresting. Production approval requires a verified runsc binding, all 18 admission controls, live denials on the target host, evidence outside the workload, and no fallback path. If one of those pieces cannot be shown, the honest status is “not admitted.”

Sources