boxxkite
← All posts
Security

Why Docker Containers Aren't Enough for Autonomous LLM Agents

"It runs in a Docker container" has been an acceptable answer to "is this isolated" for about a decade of CI runners and dev sandboxes. It stops being an acceptable answer the moment the code inside that container was written by an LLM agent a second ago, based on a prompt you don't fully control.

A container is a process, wearing a costume

A Docker container is not a virtual machine. It's a normal Linux process, given the appearance of isolation through namespaces (its own view of the filesystem, network, and process tree) and resource limits through cgroups. The kernel underneath is the same kernel every other container on that host is running on. For trusted, known workloads — your own CI jobs, your own microservices — that shared kernel is a fine trade: fast startup, high density, and the code running inside was written by your own team.

An autonomous agent inverts that trust assumption. The code executing inside the container wasn't written by your team — it was generated, possibly influenced by untrusted input (a scraped webpage, a user-supplied prompt, a tool result from somewhere else entirely), and it can decide what to run next based on what it just saw. The isolation boundary that was "good enough" for trusted CI jobs is now the only thing standing between arbitrary, LLM-generated syscalls and the host kernel every other tenant on that box shares.

Same host, same kernel

container Ayour own servicecontainer Bagent-executed codecontainer Cyour own serviceone shared host kernel
Every container on a Docker host shares one kernel. A kernel-level escape from any one of them is a host-level compromise — and a host running agent-generated code is exactly the container most likely to be handed a syscall nobody reviewed.

What changes when the agent writes the next command

It's worth being precise about why the agent case is different, because "untrusted code" on its own isn't new — CI has run untrusted pull-request code for years. The difference is the feedback loop. A CI job runs a fixed script to completion; an agent runs a command, reads the output, and decides the next command from what it saw. If any step in that loop is influenced by content the operator doesn't control — a page the agent fetched, a file a tenant uploaded, a tool result from another system — then the stream of syscalls hitting the kernel is effectively adversary-steerable in real time, not a one-shot payload you could have scanned in advance.

A tempting mitigation is to allowlist the commands the agent may run. boxxkite ships exactly that (src/boxxkite/command_whitelist.py, opt-in per agent via sandbox_allowed_commands) — but its own security docs are blunt about what it is: a guardrail against accidental or unexpected commands, not a sandbox-escape boundary. The moment the allowlist includes a general-purpose interpreter — python3, bash, node — arbitrary code can run the instant that interpreter starts. Command filtering narrows mistakes; it does not contain a determined agent. Containment has to live at the runtime boundary, which is exactly where the shared kernel becomes the problem.

The specific gaps, not a vague "containers are insecure"

This isn't an argument that Docker is badly built — it's that its isolation model was designed for a threat model that doesn't match autonomous code execution. Three concrete gaps matter here:

Kernel-level escapes are a real, recurring category. Container breakouts via a shared kernel aren't hypothetical — runc (the low-level runtime underneath Docker and most container engines) has shipped and patched real host-escape CVEs before (CVE-2019-5736 being the best-known: a crafted container could overwrite the host runc binary itself). Each one gets fixed — but the category exists precisely because the isolation is namespace-and-cgroup based, not a hardware-enforced boundary.

Default capability and root posture is generous by default.A container run without hardening still frequently runs as root inside the container, with a broad default Linux capability set attached, and a writable root filesystem. None of that is malicious — it's just the path of least resistance for "get my CI job running." It's the wrong default for a workload where the next command wasn't written by a person.

Network egress is usually wide open.A default Docker network lets a container reach the internet freely. For an autonomous agent, that's a credential-exfiltration path sitting open by default, not a deliberate decision anyone made for this specific workload.

What a real pod boundary changes

boxxkite's runtime mode gives every session its own Kubernetes pod, not a container sharing a host with arbitrary neighbors, and closes the three gaps above deliberately rather than by default-hardening after the fact. The pod template isn't a suggestion the code might drift from — the security context below is asserted at runtime and cross-checked by a parity test (test_pod_template_parity.py), so the manifest and the live pod can't silently diverge:

pod-template.yaml
# sandbox container — runs agent-generated code
securityContext:
  runAsUser: 1001
  runAsGroup: 1001
  runAsNonRoot: true
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
    - ALL
  seccompProfile:
    type: RuntimeDefault
# pod-level
automountServiceAccountToken: false

Read that field by field against the "generous defaults" gap. Execution is non-root (runAsUser: 1001) and the kernel refuses to start the container as root at all (runAsNonRoot: true). Every Linux capability is dropped (drop: [ALL]), so even code that finds a bug has no CAP_SYS_ADMIN or CAP_NET_RAW to lean on. The root filesystem is read-only, so a successful write primitive has nowhere durable to land. allowPrivilegeEscalation: false plus a RuntimeDefaultseccomp profile shrinks the syscall surface the shared kernel even exposes — and that seccomp profile is a good illustration of the "don't trust the manifest" discipline: v0.2.1 fixed it to be set on both containers at runtime, where earlier it was only advertised in the reference template, and added a parity test so the two can't drift again. Finally, automountServiceAccountToken: false means the sandbox never receives a Kubernetes API credential in the first place — there is no token in the pod for escaped code to replay against the cluster.

Boundarybare docker run (default)boxxkite pod-per-session
Userroot (uid 0) inside the containernon-root runAsUser: 1001, root start refused
Capabilitiesbroad default Linux capability setdrop: [ALL]
Root filesystemwritablereadOnlyRootFilesystem: true
Priv-esc / syscallsescalation allowed, full syscall surfaceno-new-privs + seccompProfile: RuntimeDefault
Network egressopen to the internetdefault-deny NetworkPolicy + empty netns per exec
K8s API tokenn/aautomountServiceAccountToken: false
Kernelshared with every container on the hostshared at the node; one pod per session; VM-grade optional
Bare docker run defaults versus a boxxkite pod-per-session. None of the Docker column is a Docker bug — it's the right default for trusted CI and the wrong one for agent-generated code.

Three independent layers, not one wall

The part that matters most for the network gap is that boxxkite doesn't rely on a single control. The README describes the posture as layered defense in depth, and for network egress specifically there are three boundaries that fail independently — a misconfiguration in any one still leaves the others standing:

What an agent-executed command has to get past

agent-generated execuntrusted next syscall

three boundaries

empty network namespaceunshare -n, per exec
default-deny NetworkPolicypod-level backstop
per-pod sidecar auth tokenX-Sidecar-Auth-Token
internet · IMDS · other podswhat stays out of reach
Three independent network boundaries. The per-exec empty namespace is the primary control; the pod NetworkPolicy is the backstop for everything else in the pod; the sidecar auth token doesn't depend on network topology at all.

The empty network namespace is the primary control. In Kubernetes runtime mode, every /exec call runs inside a freshly created, empty network namespace — the sidecar does unshare -n before it nsenters into the sandbox (see build_k8s_exec_command in sidecar/main.py), gated by SANDBOX_EXEC_NETWORK_ISOLATION_ENABLED, which defaults to true. The executed process has literally no network interfaces — not "restricted by policy" but physically absent. It is the tightest layer precisely because there is nothing to misconfigure: an interface that doesn't exist can't be reached.

The pod NetworkPolicy is the backstop. deploy/network-policy.yaml is default-deny on both ingress and egress with an explicit allowlist (DNS, and a storage-egress rule the operator must fill in — it intentionally ships unfilled rather than defaulting to 0.0.0.0/0). This covers the traffic that doesn't go through unshare— the sidecar's own storage-sync — and is the second independent layer if the per-exec isolation is ever turned off.

The sidecar auth token doesn't depend on network topology at all. Every sidecar route except /health requires a per-pod shared-secret X-Sidecar-Auth-Token header (see src/boxxkite/sidecar_auth.py). Even a caller that somehow reached the sidecar over the network still can't drive it without that pod's specific token — a third layer that holds regardless of what the CNI is or isn't enforcing.

The node boundary a pod spec can't set by itself

There's a subtler gap that a hardened pod template alone doesn't close. Kubernetes RBAC has no mechanism to scope pod or secret verbs to a label selector or name pattern, so the control-plane's ServiceAccount necessarily holds create/delete on everypod in its namespace, not just sandbox-labeled ones. If that credential ever leaked, an attacker could submit an arbitrary pod spec of their own — one that mounts the node's root filesystem via hostPath, turns on hostPID/hostNetworkto see the node's other workloads, or sets privileged: truefor a full escape — completely bypassing the careful security context on boxxkite's own template.

The fix is admission control, not RBAC: deploy/pod-security-policy.yaml is a ValidatingAdmissionPolicy that rejects any pod in the namespace using hostNetwork, hostPID, hostIPC, hostPath, or privileged— the actual node-compromise vectors. It deliberately doesn't use Pod Security Admission's baseline/restricted labels, because those would also block the sidecarcontainer's documented near-root requirement. Because none of those fields appear in boxxkite's real pod template, blocking them costs nothing functionally — but it turns a leaked control-plane credential from "node compromise" into "can only create pods that look like the sandbox."

Verify it — don't trust the manifest

A NetworkPolicy is only as real as your CNI's willingness to enforce it, and several common managed setups don't out of the box: GKE Autopilot without Dataplane V2 explicitly enabled, EKS's default VPC CNI without an add-on like Calico, and kind (which ships no NetworkPolicy enforcement at all, which is why deploy/local-kind/ includes no policy). So the manifest existing proves nothing; you have to check the running cluster. The highest-priority thing to verify is the cloud instance-metadata endpoint 169.254.169.254 — the policy blocks it only by omission (default-deny with nothing in the allowlist naming it), and link-local ranges have a history of CNI-specific enforcement gaps:

verify-isolation.sh
# Highest-priority check: is cloud metadata (IMDS) actually blocked?
# A timeout / connection-refused = blocked. Any HTTP status = NOT enforced,
# and the node's IAM credentials are reachable from the pod.
kubectl exec <sandbox-pod> -c sidecar -- \
  curl -m 3 -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/

# Generic enforcement check from inside a deny-all namespace:
# should hang until the 3s timeout, not return a page.
kubectl exec <pod> -- curl -m 3 https://example.com

If either command returns an HTTP status instead of timing out, your CNI is not enforcing NetworkPolicy and this file is providing no protection — verify before you rely on it, rather than assuming the YAML is "doing something." (The upstream kubernetes-sigs/network-policy-apiproject ships a purpose-built conformance tool for exactly this if you want more than the manual spot-check.) This is also why the per-exec empty namespace matters so much: it doesn't depend on the CNI at all, so it still holds on a cluster whose NetworkPolicy is silently inert.

When a shared kernel still isn't enough

Everything above narrows the shared-kernel risk; it doesn't eliminate it. A Kubernetes pod still runs on the node's kernel, so a true kernel-level escape is still a node-level event — pod-per-session means there's no co-tenant's workload sharing that exact kernel instance the way there is on a busy Docker host, but the node is shared. The genuinely stronger boundary is a separate kernel per sandbox: gVisor, Kata Containers, or Firecracker microVMs.

boxxkite ships an experimental Kata RuntimeClass variant (deploy/pod-template-kata.yaml) that schedules the pod as a genuinely separate-kernel VM — and it's a good example of the project's disclose-don't-market ethos, because it comes with a confirmed, security-relevant regression documented right at the top of the file. On a live Kata-enabled cluster (GKE + kata-deploy, 2026-07-16), emptyDir.sizeLimitwas not enforced under Kata's shipped defaults: writing well past a volume's limit left the pod running where therunc control evicted it in seconds. Those size limits exist as a cross-tenant disk-DoS control, so turning on the stronger kernel boundary currently removesa different one until an in-guest quota is built. That's the honest shape of the tradeoff — stronger isolation of one kind, knowingly weaker of another — and it's why VM-grade isolation is offered as an opt-in layer to reach for on the highest-sensitivity workloads, not flipped on as a silent default.