Why I Granted SYS_ADMIN to Untrusted Agent Containers, and Why I Accept It in gVisor (#02)

I grant SYS_ADMIN to untrusted agent containers because gVisor evaluates it inside the Sentry, not on the host. I walk through my runsc daemon.json flag by flag, compare runc with gVisor, and state the residual risk I accept.

Why I Granted SYS_ADMIN to Untrusted Agent Containers, and Why I Accept It in gVisor (#02)

The runtime boundary and daemon.json

Running autonomous LLM agents means executing untrusted, machine-generated code inside your infrastructure. If an agent needs to debug code, install dependencies (apt-get, pip, npm), and run test suites, it requires a fully functional Linux environment. Under standard container runtimes like runc, granting the permissions needed for build workflows is an unacceptable blast radius. A single escape or misconfiguration gives the agent access to the host kernel. To solve this, I run gVisor's runsc as a dedicated Docker runtime. This post details the configuration I use in /etc/docker/daemon.json, the architectural defense model behind each flag, and the reasoning behind a choice that usually terrifies security teams: granting SYS_ADMIN to an untrusted agent container. I covered why I chose gVisor in the first place in the isolation runtime post.

The diagram below shows the runtime boundary and where each flag sits. Click the boxes for details.

Show the full sectionHide the section

The configuration

This is the runtimes entry in /etc/docker/daemon.json:

{
  "runtimes": {
    "runsc": {
      "path": "/usr/local/bin/runsc",
      "runtimeArgs": [
        "--platform=systrap",
        "--network=sandbox",
        "--net-raw=false",
        "--allow-flag-override=false",
        "--overlay2=root:memory",
        "--file-access=exclusive",
        "--file-access-mounts=shared",
        "--directfs=false"
      ]
    }
  }
}

Flag by flag

Each runtime argument explicitly shrinks the attack surface exposed to the agent while managing the performance penalties inherent to user-space syscall emulation.

  • --platform=systrap: Interception platform selection dictates both performance and host requirements. While the KVM platform yields lower latency, it requires hardware virtualization access (/dev/kvm), which is often unavailable in nested virtualized cloud instances or standard WSL2 developer environments.
  • --network=sandbox: Instead of bridging the agent directly to the host's network stack, this flag directs all network traffic through gVisor’s Netstack—a complete TCP/IP stack implemented entirely in Go. Because the agent interacts only with Netstack, it never touches the host kernel's network drivers or raw socket primitives. An exploitation vector inside the Linux network stack does not compromise the host; an attacker is instead confined to the Go user-space network implementation.
  • --net-raw=false: Autonomous agents should never need to craft arbitrary link-layer frames, execute ARP poisoning, or run raw socket port scanners inside internal VPC subnets. Setting --net-raw=false (paired with stripping CAP_NET_RAW from the container capability set) disables raw socket creation at the runtime boundary.
  • --allow-flag-override=false: Prevents per-container runtime overrides via Docker CLI flags or compose annotations. I set it to keep per-container annotations or flags from loosening the runtime config.
  • --overlay2=root:memory: The container’s root filesystem writes are redirected to an ephemeral, in-memory overlay. This enforces two operational properties:
    1. Total Ephemerality: All filesystem modifications, compiled artifacts, and downloaded payloads vanish completely when the container stops.
    2. RAM Exhaustion Hazard: Because writes consume host memory, a run of pip install torch or large compilation artifacts will rapidly fill the RAM overlay. I run a container with this flag under a Docker memory limit (--memory) to reduce the risk of host out-of-memory events. I have not checked how this overlay is counted against that limit. See "Code references" for what my code sets.
  • --file-access=exclusive and --file-access-mounts=shared: This split balances sandbox caching against data integrity:
  1. exclusive tells the sandbox that no host processes will modify the root filesystem out-of-band. The Sentry caches file metadata and directory trees aggressively without re-validating against the host filesystem on every syscall.
  2. shared applies to explicitly mounted external volumes (e.g., source code repos bind-mounted from the host), forcing the sandbox to invalidate caches and respect external host file writes.
  • --directfs=false: DirectFS allows the Sentry sandbox to execute direct filesystem operations against host file descriptors for performance. Disabling it (--directfs=false) forces all file I/O to be brokered through an isolated auxiliary process called the Gofer via a private IPC mechanism.

The trade-off is stark: file I/O operations (such as traversing heavy node_modules trees) take a substantial throughput hit. In exchange, the Sentry process never receives ambient host filesystem access tokens. With directfs off, I expect a compromised Sentry to have no direct host file descriptors to read from, so the Gofer becomes the next boundary I rely on. I have not verified this.

My view: None of these flags makes the sandbox a perfect wall. Each one removes a path I would otherwise have to defend, and each leaves a smaller piece of code that I trust instead.

Why I accept SYS_ADMIN in gVisor

Standard container security orthodoxy dictates that CAP_SYS_ADMIN should never be granted to untrusted workloads. Under runc, SYS_ADMIN is functionally equivalent to host root—it exposes kernel namespaces, mount tables, eBPF, and device control. However, complex development agents require elevated capabilities to function. Below is the capability set I grant to the agent container:

["SYS_ADMIN","SYS_CHROOT","MKNOD","CHOWN","DAC_OVERRIDE","FOWNER","FSETID","SETUID","SETGID","SETPCAP","SETFCAP","KILL","AUDIT_WRITE","NET_BIND_SERVICE","SYS_RESOURCE"]

SYS_ADMIN is the one that needs a defence. On a normal Linux container runtime it is close to root on the host kernel. I grant it here because the agent needs a working build environment, and gVisor changes who evaluates the capability. The diagrams show the two cases. Click the boxes for details.

Show the full sectionHide the section

Scenario A based on runc

The container process communicates directly with the host Linux kernel. When a process requests a restricted operation (e.g., mount()), the host kernel checks if the process possesses CAP_SYS_ADMIN. If granted, the host kernel executes the operation directly within its own memory space. An unpatched bug anywhere in the kernel's mount, namespace, or device logic results in a full host escape.

The diagram shows where the capability is checked in this case. Click the boxes for details.

Scenario B based on gVisor

The container process makes system calls that are intercepted before ever touching the host kernel. They terminate in the Sentry—gVisor’s user-space kernel written in memory-safe Go. The Sentry evaluates SYS_ADMIN entirely within its emulated user namespace. When the agent calls mount(), the Sentry updates its internal in-memory virtual mount table. No host mounts are performed. The Sentry itself runs as an unprivileged host process restricted by an aggressive seccomp filter. The host kernel never sees or grants a SYS_ADMIN request.

The diagram shows where the capability is checked in this case. Click the boxes for details.

Residual risk model

Granting SYS_ADMIN under gVisor does not eliminate risk; it relocates it to an engineered perimeter: Expanded Sentry Surface: Enabling features like in-sandbox mounts forces the Sentry to run more of its own emulation code. More reachable Go code means a larger attack surface for Sentry logic bugs. Gofer and IPC Exploits: The sandbox boundary relies on secure communication between the Sentry and the Gofer process. Seccomp Escape: An attacker must first compromise the Sentry's memory space, locate an exploitable vulnerability in the reduced set of host syscalls permitted by the Sentry's seccomp filter, and then compromise the underlying host kernel.

I accept SYS_ADMIN under gVisor because defending an emulated, memory-safe user-space kernel behind a strict seccomp profile is significantly more defensible than exposing the bare host kernel to arbitrary LLM code.

What this post leaves out

This post covers the runtime flags and the SYS_ADMIN decision. These topics are out of scope.

  • Seccomp and AppArmor profile tuning.
  • The KVM platform. I use systrap and do not assume /dev/kvm.
  • Rootless runsc.
  • Network egress design. The agents-net gate handles it, in a separate package and post, and I do not discuss it here.
  • Multi-tenant quotas.
  • Cgroup and disk-quota tuning.

Run it yourself

This post argues three things: the runsc runtime is registered with one fixed set of flags, SYS_ADMIN is evaluated by the Sentry under gVisor and by the host kernel under runc, and the root:memory overlay uses host RAM. The steps below let me, and you, look at each of them with plain docker commands. They do not run the agent-sandbox lifecycle tool, which is a different topic.

Target environment: a disposable Linux host with Docker, where you can run docker as your own user and where a manual operator step (root) registers the runtime. Nothing here was run on a host by me yet.

Full runbook: the chapter 1 runbook takes a bare Windows + WSL2 machine to a running agent container, including creating agent-host with the runsc runtime registered and creating the container with the capability set above (its sections 3 and 8 cover this post's runtime).

Run this evaluation harness strictly within an isolated, disposable environment. This repository is an architectural reference pattern designed to benchmark isolation boundaries, not an off-the-shelf production system. Hardening an untrusted-code sandbox for enterprise multi-tenancy—including custom seccomp profiles, network egress filtering, and tenant quotas—requires environment-specific design.

Code references

I name functions and constants, not line numbers, because line numbers drift. Paths are relative to packages/agent-sandbox/ and link to the code tag.

Show the full sectionHide the section
What Where Name
Container creation arguments internal/services/docker.go CreateArgs (called by Docker.Create)
The create-and-start step internal/steps/s06_create_start.go CreateAndStart
Runtime, limits, tmpfs internal/config/config.go Runtime, Memory, CPUs, PidsLimit, TmpfsTmpPath, TmpfsTmpOptions, TmpfsDockerPath, TmpfsDockerOptions
Capability allowlist internal/config/config.go Capabilities
Verification after start internal/steps/s08_verify.go Verify, inspectMismatches
Entry point cmd/create_agent_container/main.go main
runsc flags (other package) packages/agent-host/internal/config/config.go RunscRuntimeArgs, DaemonJSON
Runtime registration (other package) packages/agent-host/internal/steps/s10_register_runtime.go RegisterRuntime, RunscInstallCmd

What the code passes, read from CreateArgs and config.go (code-read, not run):

  • --runtime=runsc, --cap-drop ALL, then one --cap-add per entry of Capabilities, and --security-opt no-new-privileges. The 15 capabilities are the same ones, in the same order, as the list in this post. NET_RAW is not among them, which matches the --net-raw=false section.
  • --memory 4g, --cpus 2, --pids-limit 512. These are constants. The environment overrides in config.Load cover only the log level, paths and timeouts, so memory, CPUs and pids are fixed design values.
  • --memory-swap is not passed. Docker's default swap allowance therefore applies. I have not checked how that behaves under runsc.
  • --tmpfs /tmp:rw,noexec,nosuid,size=512m and --tmpfs /var/lib/docker:rw,nosuid,size=2g. These bound only those two paths. I have not checked how the root:memory overlay counts against the 4g limit.
  • --restart no, and the network agents-net.

What the registration does, read from packages/agent-host (code-read, not run):

  • RunscRuntimeArgs holds eight arguments. They are the same eight, in the same order, as the runtimeArgs in the configuration above. I found no mismatch.
  • RegisterRuntime does not write the JSON by hand. It copies runsc to /usr/local/bin/runsc and runs runsc install --runtime=runsc -- followed by the eight arguments. I have not compared the resulting daemon.json with the block above.

What the verification checks: Verify reads docker container inspect and compares the runtime, CapDrop, the exact CapAdd set, no-new-privileges, memory, CPUs, pids limit, restart policy, tmpfs, mounts and network. It does not check swap. On any mismatch it re-pauses the container and reports CONTAINED or UNSAFE. It never removes it.

References

  • gVisor documentation: security model, platforms, networking, filesystem and Gofer, and runsc configuration flags. URLs are added once I have checked the flag wording above.
Show the full sectionHide the section
  • Docker documentation: alternative runtimes and the runtimes entry in daemon.json.
  • Linux capabilities(7) manual page, for the meaning of each capability on a normal kernel.

Code for this post (tag ch01_found/p01_p02_p03): https://github.com/vectorNvolt/vectorNvolt-blog/tree/ch01_found/p01_p02_p03/chapter01/packages/agent-sandbox