Policy Versus Physics: Docker Sandboxing for My AI SRE Agent

Policy Versus Physics: Docker Sandboxing for My AI SRE Agent

This is a follow-up to my first article about my semi-autonomous SRE agent, which covered the agent itself: investigation, scoped tools, human approval. This one covers the question I kept deferring: where does the agent's code actually run, and what happens when every other guardrail fails?Everything I have written about this agent so far has been policy. Scoped tools instead of shell access. Validation hooks on every proposed change. Human approval before anything merges. Retrieved runbooks that inform but never command.Policy is what the system is supposed to allow. It is written in code I wrote, reviewed by me, tested by me, which means it fails the way all code fails. A tool server with a path traversal bug. An allowlist with one regex that matches more than I intended. A validation hook that someone (me) disabled during a debugging session and forgot to re-enable.The uncomfortable question is not "what did I allow?" It is "what can the agent do when my policy layer has a bug?"For my agent, the answer used to be: whatever my laptop can do. The tool server ran as my user, on my machine, with my SSH keys sitting in ~/.ssh and my cloud credentials in ~/.oci. Every guardrail I bragged about in the first article was a doorman standing in front of an unlocked door.So I moved the whole thing into a padded room. Here is how the Docker sandbox works, what it caught, and where containers stop being enough.Policy Versus PhysicsThe distinction that reorganized my thinking: guardrails are policy, sandboxes are physics.A command allowlist says the agent may not run curl. A container with no network says the agent cannot run curl in any way that matters. The first depends on my string matching being smarter than a model that has read every shell trick on the internet. The second does not care how creative the model gets.You want both layers, because they fail differently. Policy fails when it has bugs. Physics fails only when the isolation mechanism itself is broken. That is a much higher bar, maintained by people who are much better at this than I am.The rule I settled on: policy decides what the agent should do, the sandbox bounds what a total policy failure costs.The Sandbox DesignThe agent has two halves, and they get very different treatment. The reasoning loop, the part that talks to the LLM API, runs outside the sandbox. It handles credentials for the model provider and nothing else. The tool server, the part that actually executes things, runs inside a container that is built to be disappointing.The container is disappointing on purpose:Read-only root filesystem. The only writable path is a tmpfs mount at /scratch, capped at 256 MB, wiped when the container dies.Non-root user. No sudo, no setuid binaries in the image, nothing interesting in $PATH beyond what the tools need.All capabilities dropped. --cap-drop=ALL. The container cannot change file ownership, bind low ports, or touch anything kernel-adjacent.No default network. The container joins an internal Docker network whose only other member is an egress proxy.Resource limits. CPU, memory, and a pids cap so a runaway loop or fork bomb degrades into a boring OOM kill instead of a host incident.Default seccomp profile, no privileged flags, no Docker socket mounted. Mounting /var/run/docker.sock into an agent's container is handing it the keys to the host with extra steps.The compose definition is short enough to read in one sitting, which is itself a feature:services: agent-tools: image: sre-agent-tools:latest read_only: true user: "10001:10001" cap_drop: [ALL] security_opt: - no-new-privileges:true tmpfs: - /scratch:size=256m networks: [agent-internal] mem_limit: 512m pids_limit: 128 egress-proxy: image: sre-agent-proxy:latest networks: [agent-internal, external] networks: agent-internal: internal: true external: Nothing in this file is exotic. That is the point. The security posture of the sandbox should be auditable by anyone on the team in five minutes, because a sandbox nobody understands is a sandbox nobody notices breaking.The Egress Proxy: Credentials Stay OutsideThe design decision that mattered most was the one about secrets: the sandbox holds no credentials at all.The tool server needs to query Prometheus, read logs, pull deploy history, and open pull requests. My first instinct was to inject scoped API tokens into the container as environment variables. Then I remembered my own warning from the first article, that logs are untrusted input, and thought about what a prompt-injected agent would do with env and a working network connection.So the credentials moved out. The container's only network path leads to an egress proxy running outside the sandbox. The proxy holds the tokens, and it exposes named routes, not the open internet:allowed routes: GET /prometheus/query → attaches read-only Prometheus token GET /logs/{service} → attaches log-viewer token GET /deploys/{service} → attaches read-only CI token POST /github/pull-request → attaches PR-scoped token, branch must match remediation/* everything else → 403, logged, alerts on repetition The agent inside the sandbox can ask the proxy for the things it is allowed to ask for. It cannot exfiltrate a token it never possessed, and it cannot reach an attacker's server that the proxy has never heard of. Even the pull request route enforces the same boundary from the first article (remediation branches only), but now the enforcement lives outside the blast radius, in a process the model's output never executes inside.Ephemeral by DefaultEach investigation gets a fresh container. When the incident closes, or when the agent hits its time budget, the container is destroyed, scratch space and all.This started as hygiene and turned out to be a real security property. Anything an attacker manages to plant inside the sandbox (a modified script, a poisoned cache, a scheduled task) has a lifespan measured in minutes. Persistence, the thing real attackers care about most, requires escaping the container rather than just compromising it.It also killed an entire class of debugging pain. "Works in this container but not that one" disappears when there is no long-lived container to drift.The Test That Justified ItI re-ran the prompt injection scenario from my earlier testing, but this time with teeth. I planted a log line in a failing service that read like instructions: fetch a script from an external URL and execute it, phrased the way these attacks actually get phrased: buried in a stack trace, addressed to the assistant, urgent in tone.The layered defenses worked in exactly the order they were designed to:The log sanitizer wrapped the line in untrusted-data markers. The model, to its credit, flagged it as suspicious in its reasoning trace.In a variant where I weakened the sanitizer and pushed harder, the model did attempt an outbound fetch through a shell tool.The command allowlist blocked it.In a third variant where I deliberately loosened the allowlist, simulating the policy bug this whole article is about, the fetch executed and died instantly: no route to anywhere except the proxy, which returned a logged 403.Layer four is the one that let me sleep. Layers one through three are policy, and I had personally introduced bugs into all of them at some point during development. Layer four is physics.The 403 also fired an alert, which is the correct final behavior: a blocked exfiltration attempt is not just a non-event, it is a signal that something upstream is compromised.Where Containers Stop Being EnoughHonesty section, as usual.Containers share the host kernel. Docker isolation is namespaces and cgroups, not a hypervisor. A kernel exploit inside the sandbox is an escape. For my proof-of-concept threat model (a confused or prompt-injected agent), that is acceptable. For an agent processing genuinely adversarial input at scale, I would reach for gVisor or Firecracker microVMs, which put a real boundary between the workload and the kernel.The proxy is now the crown jewels. I concentrated every credential into one process, which makes that process the most attractive target in the system. It gets the strictest review, the most logging, and the least code. It is boring by mandate.The reasoning loop is still outside. The LLM API key lives in the unsandboxed half. If the reasoning process itself has a vulnerability, the sandbox does nothing for me. My mitigation so far is that the reasoning loop executes no model output; it only routes tool calls into the sandbox. But "so far" is doing some work in that sentence.Sandboxes rot. Every convenience mount, every "temporary" extra route on the proxy, every debugging port left open erodes the boundary. I now diff the compose file and proxy config in CI against a known-good baseline, because the biggest threat to the sandbox is me, three weeks from now, in a hurry.The TakeawayIf you are building an AI agent that executes anything (an SRE agent, a coding agent, anything with tools), the question to ask is not "what have I allowed it to do?" but "what happens when my allow-logic has a bug?"Guardrails are policy. Policy is code, and code has bugs. A Docker sandbox with a read-only filesystem, no capabilities, no credentials, and no network except a credential-holding proxy turns a policy bug from a potential incident into a logged 403.The agent from my first article decides what to do. The sandbox from this one bounds what it costs when that decision, or the policy around it, is wrong.Build the padded room before you need it. The first blocked exfiltration attempt in your proxy logs will not feel like overengineering.If you have taken agent sandboxing further than containers (gVisor, Firecracker, or full VM isolation per investigation), I would love to hear what the operational overhead actually looks like.

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.