๐ŸŽ Corral

Security model

The boundary

Each project gets its own Linux virtual machine (Apple Virtualization framework via Lima). The agent runs inside that VM as your user, with sudo unless network = "broker" or "offline" removed it (with the root-equivalent groups). The VM sees:

Inside the box Source Mode
your project directory, at its real path virtiofs mount (source = "mount", default) read/write (or read-only with readonly_project); paths in hide are shown empty. source = "clone": not mounted at all โ€” the box clones the repository at session start and the only way back is a git push
/corral/agents/<agent> ~/.corral/agents/<agent> read/write โ€” the agent's login and settings, shared by your boxes
extra mounts you configured your choice as configured; ~, ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.docker, ~/.claude, shell rc files and ~/.netrc are refused
environment variables you forward see below values only, over SSH
the network Lima user-mode network outbound internet by default; network = "broker" allows only the egress hosts through an allow-list proxy on the Mac (decision outside the boundary, DNS blocked in the guest); network = "offline" rejects everything but the Lima gateway subnet (the Mac, DNS)
amd64 binaries and containers Rosetta 2 via Lima (rosetta = true) run inside the arm64 guest; no extra host exposure

It does not see your home directory, Keychain, SSH keys, cloud credentials, other repositories, the host Docker socket or the host's processes. A kernel exploit inside the box lands in a throwaway Ubuntu VM.

Configuration trust

.corral.toml in a repository is written by whoever controls that repository โ€” the code being sandboxed. The invariant is therefore:

Project configuration never runs on the host and never widens what the box can reach on the host.

Every config key has a trust class (internal/policy/trust.go; a test fails if a key is added without one):

Class Keys Rule
project-ok toolchains, packages, provision, hide, box_dirs, rosetta, idle_stop, golden, default_agent, stop_on_exit, disk, cpus, memory guest-only effect; cpus/memory capped against the host; provision entries must resolve (symlinks followed) to regular files inside the project; hide / box_dirs entries are confined to the project (never .git, never ..)
project-may-tighten yolo, readonly_project, agent_state (shared โ†’ seeded โ†’ isolated; shared_agent_state is its alias), git_identity, no_env_passthrough, protect_git_metadata, network (full โ†’ broker โ†’ offline), profile, env a repo may only make the box stricter (a profile is a floor for its bundle of keys โ€” see README); env accepts literal KEY=value only
trusted-only ssh_agent, mounts, git_tokens, egress, api_brokers, forward_env, env_from_host, keychain_env, env_file, max_running, memory_reserve, timeout, name refused in the project file; ~/.corral/config.toml, ~/.corral/projects/<box>.toml or flags

The same test that enforces a trust class for every key is what keeps this table honest: a new key cannot ship without a decision about who may set it.

Per-project privilege has a legitimate home in ~/.corral/projects/<box>.toml โ€” owned by you, outside the repository, so enabling the SSH agent for one project does not require enabling it for all.

A violation fails the load with every offending key listed โ€” nothing is silently dropped, because a silently ignored ssh_agent = true is exactly what a hostile repository would hope for. A project file that is a symlink is refused. This closes the class of "the repo turns on its own privileges" completely for Corral, because unlike container-based tools there is no config key that executes anything on the host.

What the repository can make your Mac run

The project mount is a live view of the checkout on your Mac, so anything the agent writes there is also on your Mac. Most of it is harmless until you run it, and most of it shows up in a diff you review. Two places do neither:

Path Executed on the host by Visible in a diff
.git/hooks/* your next git commit, git push, git checkout, โ€ฆ no โ€” untracked by design
.git/config (core.hooksPath, core.sshCommand, core.fsmonitor, core.pager, filter.*.clean/smudge, diff.*.command, alias.*) your next matching git command no

Corral therefore shadows both inside the box (protect_git_metadata, default on, a project can only turn it on): at every boot and again before every session, a systemd unit bind-mounts a guest-local copy of .git/config and an empty, guest-owned .git/hooks over the mounted ones โ€” for the top-level repository and each submodule under .git/modules. Commits, branches, fetches and pushes work as usual; hooks the agent installs run in the box only. If the shadow cannot be applied, the session refuses to start rather than run without it.

One visible cost: git rewrites .git/config atomically by rename, which cannot replace a mountpoint, so git config --local, git remote add/set-url, git branch -u and other commands that write the repository config fail inside the box with could not write config file .git/config: Device or resource busy. Make those changes on the host (they are yours to make), or use git config --global for box-local preferences. The config copy is refreshed from the host at every boot and session start.

What this does not cover โ€” it is a mitigation for mount mode, not a boundary:

The full answer is a checkout the host never runs from โ€” source = "clone" โ€” or readonly_project = true; both make the shadow unnecessary and it is skipped.

Known cross-boundary paths

These are the ways the box reaches the host today, by design, and what limits each. They are the items to weigh before running an untrusted repository.

Path Why it exists Limit today Tracked
Shared agent login โ€” ~/.corral/agents/<agent> is mounted read-write into every box (agent_state = "shared", default) Claude Code rewrites its credentials on OAuth refresh and its settings on use; read-only breaks it agent_state = "seeded" copies the login in once (read-only seed mount, first boot) so an overwrite stays inside that box; "isolated" gives a box its own empty state (log in once per box) and is what profile = "strict" uses. A compromised box can read or overwrite the login used by all the others; the OAuth token is the same secret in every copy, so isolation limits overwrite, not read, until per-box tokens exist shipped; per-box tokens depend on the vendor
Forwarded secrets โ€” agent auth variables and anything you list in forward_env / env_from_host / git_tokens the agent has to authenticate values live in the box's process environment for the session; the agent can read and send them anywhere the network allows. Forward the narrowest token; use env_from_host aliases and per-host git_tokens; the audit log records names only. For REST APIs prefer api_brokers: the token stays on the Mac and the box gets only allow-listed method+path calls โ€” (inherent)
API broker credential (api_brokers) scoped API access for review/bot work the token is resolved and held by the broker process on the Mac; the box reaches http://192.168.5.2:<port>/<name>/โ€ฆ and can make exactly the allow calls โ€” a compromised session can post the notes you allowed, not rotate the token or read other projects. Bodies are forwarded unread; method, path and status are audited 0.6.0
Open egress โ€” the box can reach the internet, the Mac (host.lima.internal) and the LAN installs, package registries, the agent's API network = "broker": only egress hosts, decided by a proxy on the Mac the agent cannot reach or change; the funnel to that proxy is still in-guest nftables with sudo removed, so a guest kernel bug is what it takes to bypass it. network = "offline" for no egress at all. A fully host-side funnel (vzNAT + pf) is a possible hardening tier, see ARCHITECTURE.md shipped (tier 1)
The project mount itself โ€” writes are live on the Mac that is the product readonly_project, protect_git_metadata, hide; source = "clone" removes the path entirely (opt-in; the repo may ask for it, never for mount); "sync" is planned shipped; sync open

What the agent can still do

Secrets

Supply chain of the box itself

Component Origin Verification
Guest OS Ubuntu 24.04 cloud image sha256 digest pinned in the Lima release you installed
System packages Ubuntu apt archive apt signature
Node.js nodejs.org/dist SHASUMS256.txt checked before extraction
Go go.dev/dl sha256 from go.dev metadata checked
Docker Ubuntu docker.io package apt signature
Claude Code https://claude.ai/install.sh (Anthropic) vendor's installer; the only curl | bash in the box
Lima homebrew-core's lima formula, built from the upstream tarball (sha256 in the formula) Lima pins the guest OS image by digest per release; doctor reports a Lima release other than the tested one
corral this repository, built locally by install.sh / the Homebrew formula from the git tag you can read it; release binaries on the GitHub release come with SHA256SUMS signed by minisign (SHA256SUMS.minisig; public key release/minisign.pub, secret key only in a GitHub Actions secret) โ€” minisign -Vm SHA256SUMS -p minisign.pub && shasum -a 256 -c SHA256SUMS
Golden image built on your Mac from the components above, once per toolchain set same inputs as a from-scratch box; corral golden lists them, golden prune removes old ones

There is no Corral container image or download server.

Provision scripts (ours and a project's provision list) are re-run by Lima on every boot, not only at creation โ€” that is what makes a golden clone's first boot cheap. Two consequences: every script must be idempotent, and a repository's provision scripts run again each time its box starts (as the box user; as root only if the script says # corral: system and the box is not offline). They still cannot touch the host โ€” the box is the boundary โ€” but review them as you would a Makefile.

npm inside the box is configured with min-release-age = 7 days to avoid freshly published packages (the window most hijacked releases live in) โ€” override with CORRAL_NPM_MIN_RELEASE_AGE in a provision script if a project needs to.

Audit

Every launch, exit, create, stop, idle-stop, delete, golden-build and golden-prune is appended to ~/.corral/logs/sessions.jsonl with box, project, agent, argv, whether prompts were skipped, forwarded variable names, exit code and duration. corral audit renders it. Guest-side controls log to the box's journal (journalctl -u corral-git-shadow, -u corral-hide, -u corral-offline).

What a box tried to reach. Containment is not detection: a box running a compromised dependency may attempt an outbound connection that the broker refuses, and the refusal alone is the signal worth keeping. For every box with a broker (network = "broker", or api_brokers), the broker child on the Mac appends each attempted destination to ~/.corral/logs/egress-<box>.jsonl โ€” host:port and whether it was allowed โ€” together with every api_brokers call (method, path, upstream status) and a marker at each session start and end. Only corral sees attempts: a caller knows what it permitted, which is a different fact. corral egress <box> shows the destinations deduplicated with counts (denied first); --log prints the record in order; --json is for scripts. The log holds names only, never payloads or bodies; it is kept for 14 days after the box is deleted and removed by corral gc. Direct connections that bypass the proxy never reach the broker โ€” they are rejected by the in-guest nftables rule and are therefore not in this log; that gap is the funnel's, not the record's (see the hardening tiers in ARCHITECTURE.md).

Reporting

Security issues: report privately via GitHub โ€” Security โ†’ Report a vulnerability on https://github.com/corral-sh/corral (GitHub private vulnerability reporting). Please do not post exploits in public issues.