Skip to content

Security: scoutapp/corral

Security

docs/security.md

Security model

Corral runs Claude in dangerous mode (no permission prompts) inside a sandbox: an ephemeral container with allowlisted egress and injected credentials. Here's what that boundary does and doesn't cover.

Protected

  • Anthropic credentials — in proxy mode Claude gets a dummy token; the host proxy injects the real one. Claude never sees it.
  • GitHub credentials — same treatment: in proxy mode GH_TOKEN is a dummy, the proxy injects the real token into api.github.com requests, and an in-container gh wrapper blocks the token-revealing subcommands (gh auth token, gh auth status --show-token). gh/git still work; neither Claude nor a shell command can extract the real token.
  • Where the real secrets live (host only) — the values the proxy injects are stored on the host, never in the container. On macOS they live in the login Keychain (encrypted at rest); the on-disk proxy-credentials.json holds only metadata (host, which header/param) with no secret value. On Linux (or macOS without the Keychain) they stay in proxy-credentials.json at 0600 — the original behavior. Either way the container has no access to that file, and corral set-cred reads the value from stdin, so the secret never lands in shell history or ps. See "Credential storage: honest limits" below for exactly how strong this is.
  • Outbound network — all egress (outer container and inner DinD containers) is forced through the allowlist proxy; non-allowlisted domains get 403. This containment is unchanged by the capture preset below — which allowed traffic is decrypted (MITM'd) for inspection/credential-injection defaults to minimal (Claude + GitHub); other allowed hosts still pass but are direct-dialed (not decrypted). Change it per project (Config → Capture): minimal / all / none / custom.
  • Filesystem — only the project workspace is mounted, not your home/SSH keys.
  • SSH keys — opt-in. You choose keys with a checklist (a global default set in Global settings, plus per-project extras in each project's Config tab; the effective set is the union). When any are chosen, corral runs a scoped ssh-agent holding only those keys and bind-mounts just its socket (not any key file). The container can use the keys (sign, push) but never sees the private-key bytes — the agent protocol has no "export key" operation, so this is :ro-proof (there's no file to read). Your real host agent (and any keys in it you didn't choose) is never forwarded. Keys live only in the agent's memory and are torn down with the container. On macOS, a passphrase you type once is stored in the login Keychain (--apple-use-keychain), so later loads are silent — the passphrase lives in the OS Keychain, never in corral, and keys stay scoped + torn-down per project. (Linux prompts each time; SSH is macOS- first.) See "SSH residual risk" below.
  • Dashboard — binds 127.0.0.1 only, every route requires a per-launch token.

What "host privileges" means

On macOS this is largely contained: Docker runs in a throwaway Linux VM, so even a full container escape is root in that VM, not on your Mac. On Linux it matters more — the container shares the host kernel, so an escape is closer to root on the machine itself.

Why escape is even a concern: with DinD on, the container runs --privileged and claude is in the docker group (near-root). --disable-dind drops --privileged — the container then gets only NET_ADMIN/NET_RAW for the firewall, a much smaller footprint.

Either way, mounted paths are within reach: given that near-root access, :ro mounts aren't a barrier — assume it can read/write anything mounted, including the workspace. Don't mount secrets you don't want it to see.

(The host shell and the host-claude chat panel are separate — see residual risks. The chat panel is read-only by default.)

Residual risks

  1. Privileged outer container. DinD needs --privileged and puts claude in the docker group — close to host root (see the platform note above: real root on Linux, root in the Docker VM on macOS). Escape is not a high bar; treat it as a real capability. --disable-dind drops the privileged flag.
  2. The container can't reach the dashboard. It's bound to host loopback, which a container can't reach, and the egress firewall blocks it too. The per-launch token is a defense-in-depth guard on the HTTP layer, not the boundary — still, treat the dashboard URL/token as secret.
  3. Dashboard grants shells + writes. It can open a container shell, a host shell, and edit workspace files — gated only by loopback + token. The dashboard can also create projects host-side: cloning repos (private ones use your ambient host git/gh credentials — no tokens are stored) into ~/.corral/workspaces/, and starting a project's container by running corral dev on the host. These run with the operator's full host privileges (same loopback + token trust basis as the host shell). Adding a repo clones an operator-supplied URL — an outbound host action by design. The Update button opens a host PTY running corral update — it replaces the running binary and rebuilds the image. If the install dir isn't user-writable it does NOT elevate on its own; it prints the exact sudo install … command for the operator to run. It's a real host shell, not a silent privileged endpoint, so consent (and any elevation) happens visibly on the terminal.
  4. "Ask Claude" chat panel is NOT sandboxed. The dashboard chat panel runs the operator's own host claude (real credentials/subscription — not the Anthropic API, not the sandboxed container instance), started in the project workspace with the operator's full host privileges. It defaults to a read-only tool set (Read/Grep/Glob, validated server-side against a whitelist), but granting Bash/Edit/Write lets it act on the host directly. Same trust basis as the host shell (loopback + token); the panel shows a persistent "not sandboxed" warning to make this explicit to the user.
  5. Dashboard editor bundle is trusted host-side code. The CodeMirror editor bundle is built at dev time from npm packages, committed, and go:embed-ed into the host binary — it runs in the operator's browser served by the host dashboard, outside the sandbox. Dependencies are exact-pinned (lockfile authoritative) and no npm runs at install/deploy time (the frozen bundle is the shipped artifact), so the supply-chain exposure is at build time, not deploy time. Rebuild deliberately and review the bundle diff.
  6. Passthrough / --disable-firewall turn off egress containment (for bootstrapping an allowlist); no network protection while active.
  7. Dangerous mode — no per-action approval; the container + firewall are the only guardrails.
  8. SSH scoped-agent — use, not steal, and a DinD cross-project window. The container can use a chosen key while it's running (push, auth anywhere that key is trusted) — that's the point. It can't copy the key out (socket only, no bytes). Two bounded caveats:
    • Usage window. While the container is up, anything in it (including a shell you open) can use the chosen keys. Only load keys the project needs; they're torn down when the container stops.
    • World-accessible socket (by necessity). The container runs as a different uid than the host user that owns the agent socket, so the socket is made connectable (dir 0711, socket 0666) — otherwise the container couldn't use the keys at all. This means any local process on your machine can also use a loaded key while its agent is alive (a single-user-Mac assumption). Still a signing oracle: no key bytes are exposed, it lives under the user-private ~/.corral, and it's torn down with the container.
    • DinD cross-project (macOS). The agent sockets live under ~/.corral (a Docker-shared path). With DinD on (--privileged), a container escape into the Docker VM can reach another running project's scoped agent socket and use its keys — still no byte theft, still confined to the VM (your Mac's real agent/keys are across the VM boundary, untouched). Bounded by container-lifetime teardown (an idle project has no live agent) and closed by --disable-dind. Linux (escape hits the real host) is out of scope for now.
  9. Credential storage: honest limits. The real isolation for injected credentials is that they never enter the sandbox — they live on the host and the host-side mitmproxy swaps them in on the way out. That's the load-bearing protection and it is unchanged.
    • On macOS the values sit in the login Keychain (encrypted at rest); on Linux they sit in ~/.corral/proxy-credentials.json at 0600. In both cases the trust boundary against other processes on your machine is the same: any process running as the same user could read the value (from the Keychain while it's unlocked, or from the 0600 file). Keychain storage buys encryption-at-rest and keeps plaintext off disk — it does not make the secret unreadable to your own other processes.
    • We deliberately did NOT ACL-pin the Keychain items to a code-signed corral binary. We prototyped it (self-signed cert + item ACL). A Keychain ACL only prompts an unauthorized reader — click "Allow" and you get the secret; it's not a hard deny. And even a clean prototype produced a storm of GUI prompts (grant → read → re-sign → re-read ≈ 9), which in real use means prompts on boot, on every rebuild/update, and from the detached dashboard daemon. The benefit was prompt-gated, not a wall, so the cost wasn't worth it. If a future need justifies it, it must solve the prompt-on-rebuild problem first.
    • corral set-cred reads the value from stdin (not argv), so the secret doesn't leak into shell history or ps.
    • Opaque Keychain metadata. A plain security dump-keychain (no -d) lists item service + account names in the clear without a prompt. So corral does NOT name items after the host — the service is a generic com.corral.creds and the account is sha256("<scope>:<host>"). A dump then shows only opaque items, so a generic keychain scraper can't read off a labeled "corral → Anthropic token" map to know what to grab. This is metadata obfuscation, not encryption: corral is open-source, so a corral-aware attacker can recompute the hash and find the item — it raises the bar from "zero effort, it's labeled" to "you must know corral's scheme," nothing more. (Note: reading a single item's value via security find-generic-password -w is silent on an unlocked login keychain; only the bulk dump-keychain -d of all secret data prompts. So targeted same-user access during your session is not blocked — consistent with the "same-user can read" boundary above.)
  10. Script secrets — injected to RUN, redacted from TRANSCRIPTS. Bash scripts (Automations → Scripts) can declare env-var secrets (e.g. FRESHDESK_API_KEY). Corral stores the value in the Keychain (same backend as credentials above, under a script:<id> scope) and injects it into the script's process env when the script runs — so the script authenticates, and the value is in the process env, never in argv/ps or a plaintext file.
    • Host-Claude redaction. Host claude may legitimately run/test a script (it gets the real value via the injected env), but its conversations stream to the browser and are captured. So corral redacts the literal secret value from every host-claude frame before it reaches the browser or the DB — if claude cats the script, dumps its env, greps a creds file, or an API echoes the key, the value shows as ‹redacted›. The redacted set is all script secrets + all proxy-credential values.
    • Honest limits. Redaction is value-based: a script that transforms the secret before printing (base64, splits it, prints a derived token) can evade it, and values shorter than 6 chars aren't redacted (they'd nuke common substrings). It's a strong guard against the common leak paths (file read, env dump, API echo), not full information-flow control. The at-rest / same- user boundary is the same as the credential store above.
  11. The egress firewall is not tamper-proof from inside — but bypassing it is self-defeating. The container has the caps to rewrite its own firewall: NET_ADMIN (always, for the firewall) and --privileged under DinD. So in principle Claude could flush the iptables OUTPUT chain / the --uid-owner egress rules and try to talk to the internet directly, off the allowlist.
    • Why it's largely self-limiting: Claude reaches api.anthropic.com through the same proxy (HTTP_PROXY=http://127.0.0.1:3128 → the allowlist-proxy → the host mitmproxy). Tearing down the proxy path to escape the allowlist also severs Claude's own connection to Anthropic — i.e. it ends the session it would need to act. It can't both leave the allowlisted path and keep talking to the model over it. This is a practical deterrent, not a cryptographic guarantee: a script could still make a one-shot direct connection (e.g. exfiltrate to an off-allowlist host) in the window before it needs the model again. The firewall raises the bar and makes casual exfiltration fail closed; it does not make a determined, in-container attacker impossible.
  12. Container/VM escape via exploits (not by design). The isolation rests on the container boundary (and, on macOS, the Docker VM boundary — see "host privileges" above). Those boundaries are software and have a history of escape CVEs (kernel/runc/--privileged cgroup and, on macOS, hypervisor/VM escapes). A sufficiently capable exploit chain running in the container could in theory break out — to host root on Linux, or (harder) out of the Docker VM on macOS. Two aggravating factors to keep in mind:
    • DinD widens the surface. --privileged + docker-group membership is close to host root already; --disable-dind drops --privileged (only NET_ADMIN/NET_RAW remain), a materially smaller escape surface. Prefer it when you don't need inner containers.
    • Prompt injection is the trigger. Claude runs in dangerous mode (no per-action approval), so untrusted content it reads (a malicious repo file, a web page, a tool result) can steer it into running an escape/exfiltration attempt. Without DinD the blast radius of a successful escape is smaller, but prompt injection driving the agent toward an exploit is the realistic path in either mode — the container/VM is the guardrail, and it is bypassable, not absolute. Don't point corral at untrusted repos/content you wouldn't run locally, and keep DinD off unless a project needs it.

Guidance

  • Keep the credential proxy on (the init default).
  • Treat the dashboard URL/token as a secret.
  • Use --disable-dind when you don't need inner containers.
  • Don't mount a workspace holding secrets you don't want Claude to read.
  • Give each project only the SSH keys it needs (Config → SSH keys); avoid running multiple key-bearing projects under DinD at the same time.
  • Treat the container/VM as a guardrail, not an absolute boundary (risks 11–12): the firewall is bypassable from inside (though doing so severs Claude's own link to Anthropic), and the container/VM can be escaped by exploits. Keep --disable-dind on unless a project needs inner containers, and don't run corral against untrusted repos or content — prompt injection can steer the agent toward an escape attempt.

There aren't any published security advisories