Trust & security
corral audits code it did not write, so it runs untrusted code by design:
your test suite against deliberately broken copies of your code, tests a model
wrote, and, in corral review, reproduction scripts a model wrote. A model can
also be steered by text inside the code it reads. The posture starts from that:
nothing a model says is trusted, and nothing a model wrote runs unconfined
unless you choose that. A verdict is decided by execution. Local certification
records are signed; repository and review ledger entries are signed when a
certify key is configured and otherwise remain unsigned and hash-linked.
SECURITY.md
is the full statement; this page is the tour.
The jail
By default corral never runs a mutant against your repository. What it does
write is its record: certify --repo and corral review write an entry to
<repo>/.corral/ledger (see The record), and certify --local
writes nothing under the repository. certify --local and
certify --repo copy the tree into a disposable sandbox — bwrap on Linux,
--jail container (docker or podman) where bwrap cannot run, sandbox-exec on
macOS — and every mutant is applied to that copy. There is no unsandboxed option
on this path: if no sandbox can start, the run refuses.
- Offline. The sandbox has no network. The one exception is vendoring a
project whose dependencies live outside the tree (
go mod vendor,cargo vendor): that step runs in a throwaway copy, inside the sandbox, with network for that step only — never on the host, because the package manager reads configuration the repository controls. - Read-only dependencies.
node_modules,vendor,.venv,venvand.bundleare bound in read-only rather than copied (macOSsandbox-execcannot relocate a directory, so there they are copied). - No keys. Model keys are removed from the environment of everything corral runs to grade your code.
- Only
/usrand the checkout. Your home directory is not visible, which is why a toolchain installed there is not either.
The one mode that touches your checkout
certify --repo --substrate workspace runs on your real checkout instead of a
copy. It is opt-in, and correct where the caller already is the isolation
boundary — an ephemeral CI runner, which is why the GitHub Action passes it.
Writes go through a rooted handle so a symlink cannot escape the repository,
originals are journaled, files that did not exist are removed rather than left
behind, and restores run on a failing command, a timeout, a panic and an
interrupt (SIGINT/SIGTERM). The residual risk, stated exactly: a process
killed outright — kill -9, out of memory, a runner reclaimed mid-step — can
leave a mutant behind, and nothing detects it. Use it on a runner, a scratch
clone, or a tree with no uncommitted work.
The test command gets your environment, minus corral’s own model keys and
your desktop session: DISPLAY, WAYLAND_DISPLAY, WAYLAND_SOCKET,
XAUTHORITY and DBUS_SESSION_BUS_ADDRESS. The suite runs once per mutant,
so a suite with UI tests would otherwise open windows on your screen again and
again. If yours needs a display, give it one in the test command itself, for
example -- xvfb-run -a cargo nextest run.
corral review runs a reviewer’s reproduction scripts in the same offline
sandbox by default, with --substrate workspace as the same opt-in for a CI
runner.
Why a hijacked model cannot change a verdict
- The kill rate is your suite’s exit code, run against each planted fault. No model reports it, so no injected instruction can raise it.
- A proven gap is execution too: a written test counts only if it fails with the fault in and passes on your real code, both run.
- The critic is advisory. Its opinion is recorded as unverified and never moves a status. Its answer is parsed strictly, so it can say only sound, vacuous or dead check, and where corral can run one test alone, the claim is checked by execution.
- Review claims are demoted unless their script reproduces, and a second seat tries to refute what stands.
- The critic must be a different model from the writer. That is the one decorrelation rule enforced; corral warns when every graded seat is one vendor, and does not refuse.
Keys and seats
- There are no default models. Every seat is one you named; a run with an unnamed seat is refused before any sandbox, store or spend.
- Keys come from the environment or the keystore (
corral secret: OS keyring, then an age-encrypted file;setreads stdin, never argv). - Seats start cold. The built-in
claude-codeseat loads no settings, MCP servers, skills or session file, so an audit never sees your ownCLAUDE.mdor memory, and no audit reads shared memory or skills. - An agent seat is confined only as far as its CLI allows.
claude-codegets no tools in certify;codex’s--sandbox read-onlystops writes, not reads, so it can still read the host. The CLI inherits corral’s environment, so an exported vendor API key reaches it and may be billed. A certify prompt carries the audited source, which can carry text aimed at a model: audit code you are willing to show a coding agent running as you. See agent seats. - A run spends money.
--max-tokenscaps an audit’s tokens across every audit seat;corral reviewhas no token cap. In the GitHub Action, GitHub withholds secrets from a pull request opened from a fork, so with no guard the run fails (exit 2, no provider key) rather than spending yours; see the GitHub Action for the guard.
The record
- Unless recording is disabled,
certify --repoandcorral reviewwrite a hash-linked entry, signed (Ed25519) when a certify key is configured (a CI runner has none unless you give it one), to a ledger directory — by default.corral/ledger, and in CI a workflow can preserve it on the orphan branchcorral/ledger.certify --repoandcorral reviewalso keep a copy of their entry in the machine ledger under corral’s state directory (CORRALAI_MACHINE_LEDGERmoves it);certify --localwrites there by default and, unless you pass--ledger, nowhere else. A machine ledger that resolves inside the audited checkout is not written, unless it is the repo ledger itself (that directory is written, as the primary entry, and no skip is printed).--no-ledgerskips the machine copy, and acertify --repotree with no commit writes no entry at all. Without a key the entry is unsigned.corral ledger verify --pub <key>checks retained entries against the ledger’s root public key (and, for an entry signed by a registered signer, the key the chain registered; see Signers). Removing an interior entry breaks its successor’s link; removing the tail leaves the remaining chain valid. Detecting tail truncation requires an external record of the expected head. The ledger owner can delete files and recompute unsigned hashes: tamper evidence is not immutability or guaranteed completeness. certify --repo --attest --transparencylogs the signed statement to Sigstore Rekor, a public, append-only log, so a record can be checked by someone who trusts neither you nor us.- A record’s own embedded key is never a trust anchor.
corral certify verifytakes the key from you (--pubkey), and refuses an unanchored record unless you pass--allow-unanchored. See Verify a record.
Signers
A ledger written by more than one machine would otherwise need every machine to
hold the one key everyone verifies against. Instead the ledger names a root
key, kept offline, and the root registers the keys that actually write: a
registration or revocation is an entry in the chain, signed by the root, so the
set of signers is part of the record and not a file beside it. We did not
publish a list of keys next to the ledger because a list a writer can edit
vouches for nothing; the cost of the chain-borne design is that the root must
sign every change, and that every entry this corral writes is format
corral-ledger-5, so an older corral cannot read any ledger this one has
appended to, signers or not.
- Register and revoke.
corral ledger signer add <dir> --name <name> --pub <hex>andcorral ledger signer revoke <dir> --keyid <id> --reason "…". Both read the root seed fromCORRALAI_LEDGER_ROOT_KEYand from nowhere else: not a file corral finds, and never the machine’s certify key. They refuse, writing nothing, when the seed is missing, whenCORRALAI_LEDGER_PUBKEYnames a different root, when the root has never signed anything in the directory (a mistyped path, an empty directory, an unsigned chain), or when the directory does not verify under the seed’s root.corral ledger signer list <dir> [--pub <hex>]shows the active signers, the entry that registered each, then the revoked ones. - The verify rule. With the root key (
--pub), each entry’s signature is checked against the key the chain had registered for itskeyidat that point; an entry signed by a key not yet registered, or already revoked, is a problem. A revocation holds only as far as the chain’s head is anchored from outside (--expect-head, the branch’s git history): delete the trailing revoke entry and the revoked key’s later entries verify clean, which is the tail-truncation limit described above.corral ledger verifyprints who signed each entry assigned by <name> (<keyid>). A key is named by its fingerprint,ed25519:plus 16 hex characters of the SHA-256 of the public key; the full key travels in its registration and is what signatures are checked against. - With no key, nothing is authorized. A keyless read does not check signatures, and it believes any signer entry that names the root, so anyone who can write the directory could appear to register their own key. Only a read under the root key says a registration was real.
- Pruning. A checkpoint on a chain with signers carries the active set
forward and is signed by the root, which is what stands in for the pruned
registrations.
corral ledger checkpointtherefore needs the root seed and--expect-head <hash>from outside the directory: a root checkpoint over a chain whose newest entries (a revocation, say) were deleted would make the revoked key permanent.
Keyless entries
A ledger entry can instead be signed keylessly, by the GitHub repository
whose Actions run wrote it. With CORRALAI_LEDGER_SIGNER=sigstore in a run
that has id-token: write, corral signs each entry’s hash through Sigstore
with the workflow’s OIDC identity and stores the Sigstore bundle in place of an
Ed25519 signature (format corral-ledger-6, keyid sigstore). There is no key
to keep, which is the point on a disposable runner; the cost is that trust moves
from a key you hold to a repository identity and to Sigstore’s public-good root.
We did not reuse the root-key path because an ephemeral runner cannot hold a
root key, and we did not accept “any GitHub identity” because that would make
every keyless entry from every repository look alike.
Keyless signing has been exercised only against an in-process test Fulcio and
Rekor; it has not yet been run end to end in GitHub Actions. The run must have
the id-token: write permission, which keyless signing requires.
Verification is therefore against a repository you name:
corral verify --ledger <dir> --ledger-repo owner/name
A keyless entry verifies only if its certificate names exactly that repository
and its bundle checks against the Sigstore root embedded in the release.
--ledger-repo is required only when the chain holds a keyless entry; without
it that entry fails with keyless entry: the ledger's repository is not named (pass --ledger-repo owner/name). It must be owner/name and is never guessed
from the scan’s repo, the directory or git config, because the repository the
entries claim is the thing being checked. corral ledger verify and corral ui
take the same flag.
corral ledger checkpoint and corral ledger signer add|revoke|list verify
under the root key only, so they cannot operate on a ledger that holds keyless
entries. Each refuses before writing or deleting anything, saying the ledger
holds keyless entries: signer changes and checkpoints are root-key operations
on key-signed ledgers, and pruning would delete entries a repository’s
identity vouched for.
Entries written this way are corral-ledger-6, and a corral release before
the one that introduced them cannot read, and refuses the whole ledger, once it
holds one. Key-signed
and unsigned entries are still corral-ledger-5.
Honest scope
corral measures whether a suite catches planted faults and proves the gaps it can; it does not guarantee the correctness of your code, and it does not call an unproven survivor a bug. A generated test’s pass/fail distinction does not establish that its assertion matches the intended specification. Review it and run it in your full suite: tests proven individually can still interfere when combined. A reproduction script’s successful exit likewise does not establish that its interpretation of a defect is correct. It does not make a model immune to instructions in the code it reads — the control is that no model’s word decides a verdict.
The optional daemon
The human gate
Nothing that changes what a future audit run does — a memory promotion, a
skill proposal, a fleet-published skill, a promoted control, a superuser
grant — happens without a human approving it. One rule, isHumanAdmin,
guards every admin write path — a count is deliberately not quoted here,
because a number in prose goes stale the moment a path is added and then the
page is the thing lying. Among them (approve_proposal, reject_proposal,
add_memory(shared=true), promote_memory, promote_reference, the UI’s
proposal-approve/reject endpoints, sync_put/sync_delete,
promote_endpoint, and the four principal-management writes): isAdmin
and no subagent claim on the token. This is the same gate described in the control gate
tools and the learning loop,
stated here as the security property it actually is — an agent cannot
unilaterally make itself, or a later agent, more trusted.
In dev mode there’s no cryptographic identity to check, so the same rule
becomes a truthfulness guardrail, not a security boundary: a session that
names itself corral-agent at the MCP handshake, or that calls
bootstrap/report_host (every shipped worker does; corral-admin never
does), is marked a worker for that session’s life and refused at the same
gates — workers propose, the operator disposes.
Delegation, and its documented limit
An agent can spawn an out-of-process subagent with a scoped, TTL-bound delegation token: the subagent acts under its own identity, accountability rolls up to the spawning principal, and the token dies on schedule (depth- and fan-out-capped). In-process subagents share their parent’s session and token, so they’re indistinguishable from the parent at the gate — the boundary is per-session, and out-of-process delegation is the spawn mode that actually matters for autonomous workers. This is a disclosed limitation, not a hidden gap: it’s why the human gate is described as per-session enforcement, not per-agent.
Observer tokens
A read-only observer token, minted via mint_observer (or
corral-admin mint-observer), grants view-only access to a brain’s live
state. corral-observe uses exactly this token shape, and as defense in
depth refuses non-GET methods locally even if handed a non-read-only token by
mistake. Full write access is scoped to authenticated members and superusers
via the brain’s own principal store, seeded day-0 from
CORRALAI_ALLOWED_PRINCIPALS/CORRALAI_ADMIN_PRINCIPALS and canonical in the
database after.
Sandbox jails
The daemon’s agent-executed commands run inside the same sandbox boundary (internal/sandbox) —
a bwrap namespace jail on Linux, workspace-confined with secret-free env —
rather than directly against the operator’s own filesystem. The demo’s
CLOBBER=1 mode (see claims & leases)
still runs inside this same sandbox; it only disables coordination claims,
not the sandbox itself.
Egress scan
Before the brain pushes a mission’s branch and opens a pull request, it scans the
cumulative changed files for problems the herd must not ship (internal/egress).
Committed secrets — API keys, tokens, private-key blocks — are blocking:
the push is withheld, a critical finding is filed, and the mission is parked until
an operator intervenes. New or vulnerable dependencies and license conflicts are
flagged as advisory findings. This is the last brain-side checkpoint before code
leaves for any forge — the forge-agnostic floor beneath GitHub’s own secret
scanning, which self-hosted Gitea and GitLab don’t get. Containment runs both
ways: the jail bounds what an agent can do; the egress scan
vets what it can ship.