Skip to content

Security: sudoTomas/embry0

SECURITY.md

Security Policy

embry0 runs autonomous coding agents on your infrastructure: it clones a repository into a Docker sandbox, lets an LLM agent write and run code there, and opens a pull request with the result. This document says what embry0 protects, where its trust boundaries are, which of them hold today and why, and which do not. Read What does not hold before you point embry0 at a repository or hand out API keys.

Reporting a vulnerability

Please do not open a public issue for security problems.

  • Use GitHub private vulnerability reporting: "Report a vulnerability" under the repository's Security tab.
  • If private reporting is unavailable to you, open an issue that asks for a private channel, with no details of the problem. This project publishes no e-mail address.

You will get an acknowledgement, and a fix or mitigation plan will be coordinated with you before any public disclosure.

In scope: anything that breaks a guarantee under What holds. For example: sandbox code reaching the orchestrator, the Docker daemon, the host's network, another sandbox or a forge token; a sandbox profile that weakens Ring 1 or leaves the egress drop; a proxy forwarding outside its enrollment's scope, or serving a request without a valid enrollment; a reserved environment variable reaching a sandbox; an unauthenticated or under-tiered API call outside AUTH_DEV_MODE; a sandbox or proxy running an image other than the one embry0 pushed.

Out of scope: the exposures listed under What does not hold, as described there. That includes an LLM credential read inside a sandbox, a Ring 3 or Ring 2 bypass on its own, and code from a repository granted Docker access controlling the daemon. Also out of scope: deployments with AUTH_DEV_MODE=true or WEBHOOK_DEV_MODE=true, which are documented and loudly logged development bypasses, and denial of service against your own instance. A way to reach a guarantee through one of these, for example reaching the Docker daemon without the grant, is in scope.

Supported versions

Only the latest release / main branch receives security fixes.

Threat model

Assets

Asset Where it lives
Forge tokens (GitHub and GitLab) The credential registry, Fernet-encrypted with ENVIRONMENT_SECRET_KEY (credentials.md), or the orchestrator's environment. At run time, in the forge proxies' memory.
LLM credentials: a Claude subscription OAuth token, an Anthropic API key, an xAI API key or xAI subscription token The credential registry or the orchestrator's environment. At run time, in each sandbox's environment (see below). The xAI subscription token is the exception: it stays in xai-proxy.
Operator API keys and sessions: the master API_KEY, database API keys, SSO sessions, plus PROXY_ADMIN_TOKEN and the webhook secrets The orchestrator's environment. Database keys are stored as SHA-256 hashes.
Environment secrets: global and per-repo environment variables marked secret The database, Fernet-encrypted. Injected into sandboxes: global ones into every sandbox, per-repo ones into their repository's.
The deployment host Runs the compose stack. By default that includes a privileged Docker-in-Docker (DinD) container that runs every proxy and every sandbox; with AGENT_RUNTIME=rootless they run on a dedicated rootless Docker daemon owned by an unprivileged account instead (provisioning.md §10).

Target-repository code is an asset too. Agents work on it only inside a sandbox, which is deleted when the job ends, and the orchestrator never checks it out on the host. Copies of it do outlive the sandbox, in the database and on the sandbox daemon, and by default they are kept forever: Data retention lists each one and the setting that bounds it.

Trust boundaries

Party Trusted with Boundary that applies to it
Admin tier: the master API_KEY, admin-scope keys, SSO embry0-admin Everything. Credentials, Docker grants, revealing secret values, agent definitions, integrations, users, watcher stacks, minting any key. API authentication only.
Operator tier: operate-scope keys, SSO embry0-operator Running work: jobs and issues, repository registration, env vars, QA configs, sandbox profiles, pipeline templates. Operator access means the holder can run code in a sandbox (see below). Cannot read a secret back through the API, change credentials, grant Docker access, weaken a sandbox's isolation through a profile, or mint a key above its own tier.
Read and merge keys Reading, except container logs; a merge key may also decide merge approvals, accept watcher findings and answer agent questions. Every other mutation is refused, and so are the container-log routes.
Issue authors and repository content Nothing. Whoever can put the trigger label on an issue starts a job whose task is that issue's text, and the repository's code, tests and dependencies run in the sandbox. Everything they influence runs inside a sandbox.
The sandbox: the agent, and every process it or the repository starts Nothing. Treated as hostile code. Ring 1, the proxies' enrollment and scope, the egress drop and sandbox isolation, the reserved environment variables.
The proxies (git-proxy, github-proxy, gitlab-proxy, auth-proxy, xai-proxy, context-proxy, local-llm-proxy, minio-proxy, presign-proxy) The credentials they inject. Reached by sandboxes on the sandbox-restricted network; every credential proxy authenticates each request by its per-sandbox enrollment.
DinD Every proxy and every sandbox. It runs privileged. Its daemon is reachable only with its TLS client certificates: the orchestrator's, and those of a sandbox granted Docker access.
The rootless sandbox daemon (AGENT_RUNTIME=rootless, instead of DinD) Every proxy and every sandbox. It runs as an unprivileged account, in its own user and network namespaces. Its unix socket only, mode 0660, mounted into the orchestrator; no sandbox is ever given it. The orchestrator refuses to start unless the daemon reports rootless.
The in-stack registry The proxy and sandbox images DinD runs. A login, and digest pins (below).
The public origin: the dashboard tunnel and the webhook tunnel Nothing beyond what they authenticate. SSO on the dashboard; a signature or shared secret on each webhook route.

What holds

Ring 1: container isolation. Each job runs in its own container on the DinD daemon, as the unprivileged agent user, with --cap-drop=ALL, --security-opt=no-new-privileges, and memory, CPU and PID limits. The container, and the checkout in it, is destroyed when the job ends; Data retention lists what outlives it. A sandbox profile can set the limits, but not the capability drop or no-new-privileges: the API and sandbox creation both refuse a cap_drop without ALL, any cap_add, and any security_opt other than no-new-privileges.

Credential proxies with per-sandbox enrollment (ADR 0002). The orchestrator starts the proxies inside DinD with ProxyManager:

Proxy Holds Serves a sandbox
git-proxy the forge token git's smart HTTP for the job's repository
github-proxy, gitlab-proxy the forge token read calls on the job's repository
xai-proxy the xAI subscription token grok model calls
context-proxy a service-scoped orchestrator API key log queries, for jobs enrolled for them (opt-in)
local-llm-proxy the local model server's key, if it checks one POST /v1/chat/completions and GET /v1/models on the one server LOCAL_LLM_UPSTREAM names, for jobs that resolved the custom provider (opt-in)
auth-proxy an Anthropic API key nothing: only with AUTH_PROXY_ENABLED, and no sandbox uses it
minio-proxy, presign-proxy no credential QA artifact uploads and presigned-URL refreshes

Each sandbox is enrolled with a bearer of its own, which the proxies keep only as a SHA-256 hash and look up by that hash. Enrollment needs PROXY_ADMIN_TOKEN, which the orchestrator refuses to start without outside the dev modes. Repeated wrong admin tokens lock the admin endpoints for a cooldown. Every enrollment has a TTL (PROXY_ENROLL_TTL_SECONDS), the registry is capped, and teardown revokes the bearer. Everything a proxy keeps per sandbox (a scope, a quota, an xAI account) ends with the enrollment. A proxy image too old to confirm the scope it was given is unenrolled again, so it serves that sandbox nothing; for git-proxy the sandbox is not created at all.

Forge tokens stay out of the sandbox, and are scoped to the job's repository.

  • git-proxy is the sandbox's git remote for the job's repository (ADR 0007). It streams clone, fetch and push to the forge and adds the token itself, for that repository only. It accepts a push only from a sandbox enrolled to push a branch, which is the issue-to-PR developer's sandbox, and only to that branch: it reads each push's ref updates before the pack and refuses the whole push if one names another branch, the default branch or a tag, or deletes a branch (ADR 0009). It refuses LFS, dumb HTTP, dot segments and percent-encoding, sends the token only to the host it was enrolled for, and does not follow redirects. Its old credential-helper endpoint answers 410.
  • github-proxy and gitlab-proxy forward only GET and HEAD on an allowlist of the job's repository's resources, checked segment by segment, and refuse everything else with a 403. No sandbox-side code calls them today; the orchestrator opens pull requests and posts comments with its own client.

So from a sandbox, the operator's forge token can read the job's repository and, in the issue-to-PR sandbox, push to it. It cannot reach any other repository, the forge's write APIs, or the token itself.

Egress drops and sandbox isolation. The sandbox-restricted network has no NAT, so it leads nowhere outside DinD. On it, a sandbox can open a connection to the proxies and to no other sandbox. One chain in DinD, EMBRY0-SANDBOX-ISOLATION, jumped to from DOCKER-USER for traffic within that bridge, drops every new connection between two containers on it unless it is addressed to a running proxy. The chain is rewritten from the running proxies at startup, before each sandbox is handed to a job, and when a paused sandbox resumes. The write is refused unless the kernel passes bridged traffic to iptables. If the chain cannot be written and verified, the sandbox is destroyed, or a resume stays paused.

Sandboxes that need the internet (the Claude CLI calls its API from inside the sandbox) are also attached to sandbox-internet, and every container on it gets DOCKER-USER rules in DinD that drop traffic to 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918), 100.64.0.0/10 (carrier-grade NAT) and 169.254.0.0/16 (link-local). The exceptions are the targets a profile declares (its extra_hosts addresses, a private frontend_url), the host of a custom OpenAI-compatible LLM endpoint the job's agents run on (the operator's base URL, docs/providers.md), and the running proxies' own addresses on that network. An exception is a whole address, every port on it, so put such an endpoint on a machine that serves nothing else. The proxy exceptions are one chain, EMBRY0-SANDBOX-EGRESS, shared by every sandbox and rewritten from the running proxies at startup, before each sandbox is handed to a job, and when a paused sandbox resumes, so a sandbox that outlives a restart follows the proxies' new addresses and keeps no exception for an address Docker may give to another sandbox. That keeps a sandbox off the host's LAN, the compose networks (database, orchestrator, registry) and cloud metadata endpoints, and off other sandboxes' sandbox-internet addresses. If the rules cannot be installed, the sandbox is destroyed rather than run without them.

Reserved environment variables. One check, env_override_refusal in embry0/execution/auth_provider.py, is applied at the API and again when a sandbox starts, to stored variables and to a profile's env_defaults, case-insensitively. It refuses the infrastructure credentials and proxy URLs, and every variable that would redirect a sandbox's traffic or load code into its processes: proxy routing, TLS verification, the dynamic loader, PATH, HOME and SHELL, shell and interpreter startup hooks, and the whole GIT_, CLAUDE_, BUN_ and GROK_ namespaces. NODE_OPTIONS and the JVM option variables accept memory flags only. A refusal never echoes the submitted value.

The Docker grant. A profile's dind_enabled only asks for the shared Docker daemon. A sandbox gets the daemon's client certificates only when an admin has also granted its repository Docker access (PUT /api/v1/repos/{repo}/-/docker-access, audited); otherwise the sandbox is refused before it is created. With AGENT_RUNTIME=rootless no grant opens the daemon: every dind_enabled profile and QA mode: dind is refused before anything is created.

Profile isolation fields. Sandbox profiles are operator tier, and a profile cannot weaken Ring 1 or leave the egress drop. One check, profile_isolation_problems in embry0/execution/profile_isolation.py, is applied at the API and again when a sandbox is created: cap_drop must include ALL, cap_add must be empty, security_opt holds only no-new-privileges, base_image names an image in embry0's own registry without a registry host, and extra_networks holds only sandbox-internet. A sandbox on a stored profile that holds anything else is refused before it is created.

An agent's tools, skills, system prompt and MCP servers are the operator's. An agent runs with what its definition (admin tier) and its pipeline template step (operator tier) give it, and with the MCP servers embry0 itself adds for one run (the QA browser backend, the log tool). Triage reads the issue, so its decision can only narrow:

  • Tools. A tool the operator's list lacks is not added (triage_tools_not_granted). A list triage empties keeps only the read-only tools (Read, Glob, Grep) it had, which may be none.
  • Skills. A skill the operator's list lacks is not added (triage_skills_not_granted). The one exception is a skill the agent's row lets triage add (triage_may_add_skills, which the admin tier writes; the built-in developer's names superpowers:brainstorming and superpowers:writing-plans), and only from a source the agent's list already loads. So no source's plugin, nor its hooks, loads from a triage request. A request that leaves no skill hides every skill, the repository's own included.
  • System prompts and MCP servers. The ones its decision names are ignored and logged (triage_system_prompt_ignored, mcp_servers_ignored).

An agent whose tool list is empty has no tools: the CLI offers it none and the hook allows none. Only an agent whose tools are not named at all runs with its type's default tools.

A server the CLI starts as a command (stdio) can run anything in the sandbox, so only an agent the operator gave a shell tool (Bash, Monitor, in its definition or its template step) gets one; for any other agent it is dropped and logged (mcp_servers_dropped). This is decided from the operator's tools, not from triage's narrowed list, so triage's narrowing cannot switch an agent's servers off. It follows that triage taking the shell away from such an agent is not a boundary: the agent keeps its servers, which run commands, so the operator's grant of a shell is what counts. The one exception is the log tool's server, matched on its whole config: it starts as python3 -P -s (below), so it imports only what the image holds. The QA browser servers are not exempt. They are Node programs that load modules, and start browsers, from places the agent can write, so an agent with browser tools and no shell tool runs without its browser. Saving such an agent says so, and so does saving a template whose step's tools leave its agent without a shell. The Claude CLI starts these servers and no others: not those of the workspace's .mcp.json, nor those of a mounted skill source.

A pull request review that runs nothing takes every shell tool and every server that runs a command away from the reviewer (pr-review.md). That alone does not stop a skill's inline commands (its ! lines), which run with no tool call: refusing them for an agent with no shell tool is the skills check of #327, which ships with this.

embry0's own Python in the sandbox loads only the image's code. The sandbox runner, which runs the agent and its tool checks, the log tool's MCP server and the QA ready check run as the agent user in /workspace, where the repository is checked out. They start as python -P -s. -P keeps the working directory off sys.path, so a repository's own embry0/ or urllib/ package does not load in their place. -s keeps the agent's user site-packages off, so a .pth file or a usercustomize.py that code the job ran wrote there (a pip install --user, say) does not run in them. What they import comes from the image: /app and the system site-packages, which the agent cannot write. They still run as the agent, in the job's sandbox, so code the job runs can still affect them through the sandbox itself, for example by signalling them: Ring 1 is the boundary for that.

embry0's own git commands in a sandbox. embry0 runs git in a job's sandbox for its own steps (the clone, a salvage commit and push, the changed-file diff, a review's checkout, the verify gate, an areas or audit delivery), as the agent user, in the agent's workspace. Those commands read no program from anything the agent can write (sandbox-git.md): their global configuration is a root-owned file embry0 writes, never the agent's ~/.gitconfig; hooks, the fsmonitor, signing, credential helpers, editors, pagers and submodule recursion are pinned off at command scope, and transports are limited to http and https; the repository's own .git/config (and .git/config.worktree) is checked first against a list of key families that run nothing — lfs.* is not among them, because git-lfs runs its extension and customtransfer keys — and the step is refused, naming the keys, when it carries anything else; and no embry0 git starts inside a nested repository. One module, embry0/execution/sandbox_git.py, builds every such command, and a test refuses a git command written anywhere else. So an agent with file tools and no shell cannot run a program through embry0's git; an agent with a shell gains nothing it did not have, and its own git keeps the repository's hooks and configuration. The per-sandbox bearer and the proxy admin token reach the sandbox and the proxies on stdin, never on a docker exec command line.

embry0's own file copies to and from a sandbox. embry0 stages files into a sandbox (a delivery's file, a resumed session) and reads files back out (a session blob, a boot screenshot, a job's deliverables). It never uses docker cp, which copies in the daemon as root and follows a link at the path it is given. One module, embry0/execution/sandbox_files.py, moves every such byte (a guard test keeps other modules off docker cp, off a docker exec as anyone but the agent, and off the archive API). A copy in is written as root into a fresh root-only directory the job cannot plant into, created O_EXCL/no-follow, and a caller that needs it at an agent path copies it there as the agent, never following a link at the destination. A copy out runs as the agent, not root: the agent only ever gets bytes it could already read itself, so a path that resolves to a file it cannot read — however its parent directories are arranged, and even if a parent is swapped during the read — yields nothing new; the read also refuses a non-regular file (a FIFO never blocks it) and a file past a byte limit, and the orchestrator kills the read once its pipe carries more than that limit, so nothing written into the pipe can make it buffer an unbounded amount. The deliverables listing is read NUL-delimited and capped, every path is refused unless it stays under the deliverables directory, and a name with a control character is skipped.

API authentication. Every API route needs an API key or an SSO session of a sufficient tier; the event streams also take a short-lived token, scoped to one job or run, that such a caller mints. The hosted MCP endpoint passes the caller's key through. The exceptions are health, the SSO login flow, the webhook and Telegram routes (each checks its sender's signature or shared secret), the internal QA route (per-sandbox bearer), and /metrics, which takes no credential at all: it is served only on the orchestrator's port 8000, which publishes no host port and which no ingress proxies, so only containers on the compose networks, such as Prometheus, reach it (deploy.md). AUTH_DEV_MODE is the only switch that turns API authentication off. The orchestrator refuses to start with an empty API_KEY without it, and refuses either dev mode while SSO or a tunnel token is configured, because either means a public origin.

Repository-restricted keys. A database API key can carry a repository allowlist. Such a key is refused (403, api_key_repo_refused in the log, an apikey.repo_refused audit row the key cannot read back) at every route that starts, continues or stops work on another repository, or changes its registration, its configuration or its organization: creating jobs and issues, accepting or closing a case, ticking the watcher, baking a QA image, bootstrapping a repository, and registering or changing a repository (its registration, variables, preferences, test setup or credentials) or an organization (its registration, settings or forge credential). It revokes only keys within its own list. Resuming, answering, cancelling or reviewing another repository's job, deciding its merge, and updating, triaging, syncing, deleting or answering a question on another repository's issue, is refused and recorded the same way, but answers as for a job, a merge approval or an issue that does not exist (404), so the answer does not tell whether it exists. A write on its own item that would reach another repository's work is refused without naming that repository: deleting an issue that would cancel a child issue or a job there, accepting a finding whose retry runs on an issue there, answering an issue's last question when the job it resumes is there, and sending back a job whose pull request is there. No route starts a job for such a key outside its list. The check runs in the API, so a client holding the key, including an agent driving the MCP tools, cannot talk its way past it. The list is also a read boundary: the routes api.md lists narrow what the key reads to its repositories today, jobs, their streams and what names them, and issues, their children and their agents' questions among them, and the cases, traces, stats, audit and container log reads follow. Another repository, registered or not, reads as one nobody registered, with nothing stored for it: its registration, configuration, credentials, setup state and settings layer, none of its jobs, and nothing resolved or read from the forge for it. What belongs to an owner as a whole (its organization, needs, discovery runs, webhook, standards, settings layer, forge credential binding and maps of no repository) reads only to a key that covers the owner as a whole, and even then names only the repositories its list covers: discovery's needs and counts are narrowed to them; lists name only the repositories the key covers. Repository discovery lists, to such a key, with its own credentials alone and only its own repositories. A stream token is checked when it is minted: for its 15 minutes it outlives its key being revoked and keeps what the key's list covered then. It changes nothing that belongs to the deployment rather than to a repository or an owner, such as the global variables, the agents, the sandbox profiles, the pipeline templates, the hosts, the people, a repository's Docker access, a credential for a whole owner or a credential used beyond its list, and it covers an owner only when it covers every repository registered under it. A restricted key cannot mint keys or lift its own list.

Container logs. GET /api/v1/logs/query and GET /api/v1/logs/containers read production logs, which carry personal data, so they need operator tier: a read or merge key gets a 403. The one read-scope key they admit is the context-proxy's own service key, rotated at every boot. Every line they return is redacted first, whoever asks (embry0/safety/redaction.py, with the operator's LOG_REDACTION_FIELDS): secrets, e-mail addresses, long opaque runs and those fields' values. The redactor is regex-based, so a name or an address in free text can still come through. See api.md.

The public origin. The dashboard tunnel fronts the dashboard behind SSO through a dedicated nginx vhost, rendered from DASHBOARD_PUBLIC_HOSTNAME. That vhost returns 404 for the webhook, Telegram and internal paths, and sends a Content-Security-Policy and HSTS. The tunnel refuses to start without a valid hostname rather than open a catch-all ingress (sso.md).

Secrets at rest and in output. Credentials and secret env vars are Fernet-encrypted in the database. Credentials are write-only: no endpoint returns one. A secret env value can be revealed only by the admin tier, and each reveal is audited. Every other caller that can list env vars gets **** and a preview that shows at most the last 2 characters of a secret of 12 characters or more, and nothing of a shorter one. embry0 config, the settings object and the startup logs mask every secret setting, and the password in any URL.

The in-stack image registry

The compose stack's registry holds the proxy and sandbox images that the Docker-in-Docker daemon runs. Two guards keep a replaced image from running:

  • The registry refuses anonymous clients. registry-auth-init generates a random login on first start, in a volume that only the registry, init-push-images and the orchestrator mount. This stops a casual push from another service on the backend network or a process on the host. It is not a strong boundary alone: the registry speaks plain HTTP on the backend network, so the login crosses it in clear text.
  • embry0 images run by digest. init-push-images records the digest each push reported, and the orchestrator launches the proxy and every builtin sandbox image as …:<tag>@<digest>; a pre-built QA image is recorded with the digest of its own push. Docker resolves such a reference by the digest alone and checks the content against it, so pushing a different image under the same tag, with or without the login, changes nothing that runs. The orchestrator refuses to start without these pins.

Not covered: an image a custom sandbox profile names comes from embry0's own registry (#553) but runs by tag unless it is one of the pinned images or names its own digest; a pre-built QA image baked before digests were recorded runs by tag until it is rebuilt; and whoever can write the image-pins volume, or reach a Docker daemon directly, is past both guards.

What does not hold

These are known and accepted, or not yet closed. They are stated plainly so you can decide what to run, and with which credentials.

The LLM credential is in the sandbox environment. SandboxManager puts the agent's credential into the container's environment: CLAUDE_CODE_OAUTH_TOKEN for a Claude subscription, or ANTHROPIC_API_KEY, XAI_API_KEY for an xAI API key, and for an OpenAI-compatible provider (docs/providers.md) its key under the reserved EMBRY0_LLM_ names: EMBRY0_LLM_OPENAI_API_KEY, EMBRY0_LLM_OPENROUTER_API_KEY, EMBRY0_LLM_GOOGLE_API_KEY, EMBRY0_LLM_GROQ_API_KEY, EMBRY0_LLM_DEEPSEEK_API_KEY or EMBRY0_LLM_CUSTOM_API_KEY. This is deliberate. Subscription OAuth stays supported, and the Claude CLI reads its credential from its environment, so it is not proxied (ADR 0008). Public egress is open, so any code a job runs can read the credential and send it anywhere: the agent, a test, a dependency's install script, or a command an issue talked the agent into. Treat the credential as exposed to every repository and every issue author whose work you run. Use a dedicated account with a spending limit, and rotate it if you suspect misuse. Only the xAI subscription token stays behind xai-proxy, and a custom provider reached through local-llm-proxy (LOCAL_LLM_UPSTREAM) puts neither its key nor its server's address in the sandbox; for each the sandbox gets a per-sandbox bearer. Turning auth-proxy on does not take a key out of the environment.

Environment variables, secrets included, are in the sandbox environment too. Global variables are injected into every sandbox and a repository's own into its sandboxes, so the same code can read them. Encryption at rest and the admin-only reveal protect them from the API, not from a sandbox.

Ring 3 is a regex denylist: defence in depth, not a boundary. The PreToolUse hook refuses a tool outside the agent's allowlist, and a shell command that matches one of 39 published regular expressions (embry0/safety/patterns.py), after NFKC normalization, or that runs a Docker client or reads a file named .env anywhere in it. A Docker client is docker or dockerd by the command's name, wherever it is, or a docker-* command run by its bare name or from the image's system directories (/usr, /bin, /sbin, /opt, /snap); a repository's own ./docker-build.sh is not one. A read of .env is a read of a file of that name in any case or of an editor's backup of it, or of a glob that could match it. A plain glob is read with bash's default options, so *, ? and [ab] do not match a leading . and cat * does not count; a glob that starts with a literal . is matched case-insensitively (cat .EN?). A glob a shopt, GLOBIGNORE or the like in the same command may widen is read without the leading-. rule and case-insensitively, since those options could make it match .env. Glob options do not carry from one Bash tool call to the next (verified on both CLIs), so only the command's own options widen its globs. Both are found through substitutions, runners, -c strings, functions, aliases and values, as the Ring-2 parse below reads them (embry0/safety/command_checks.py). The hook checks Bash's commands, Monitor's (no agent is offered Monitor), and the commands a skill runs (below). Both rules are best effort: they see what the command's text says, not what only the running command decides (a name it builds at run time, a Docker client copied or linked under another name, a file it copies or renames before reading the copy, a copy another tool keeps, a file a program sends by its own option), and Ring 1 is the control for those. A sandbox holds the Docker daemon's client certificates only when its repository has the Docker grant, and such a repository's code is trusted (below). It catches accidents and naive attempts, such as printenv, cat /proc/... or echo $..._TOKEN. It matches command text, so a program the agent writes and then runs is not checked at all.

Ring 2 is a set of path rules: defence in depth, not a boundary. The rules deny the agent's tools access to /etc, /proc, /root and the home directory's credential folders, and deny writing to /usr, /bin, /sbin and .claude/settings*.json. For a Claude agent two layers apply them, and nothing in the sandbox can change either: the Claude CLI, which receives the rules on its command line, and embry0's PreToolUse hook, which runs in the runner's own process. The direct executors apply them before every tool call. The sandbox's .claude/settings.json holds a copy of the rules that neither layer depends on.

  • What they catch. The file tools: Read, Write, Edit, NotebookEdit, Glob and Grep. A Glob or a Grep is checked by its search root; the CLI then leaves every file a rule covers out of the results of a search rooted above it, such as one over / or ~ (the direct executors filter their results the same way). Shell commands, parsed as bash parses them (embry0/safety/bash_paths.py): each path named by an operand, a redirection or a command substitution, after quotes, escapes, line continuations, ~, ~user and $HOME are resolved. A variable names each literal value the command gives it (X=…, export, declare, local, readonly, printf -v as printf prints it, an array, a for loop, set --, a function's or a shell's arguments, ${X:=…}), and ${X:-…} and its kin name their word too. The check reads the command up to seven times, each time with the values the time before found, so a value reaches one more variable each time; a command whose values are still changing when the last reading ends is refused, since a path it names may not be known yet. A value that grows each time (X=$X/a in a loop, PATH=/x:$PATH) is read the last time as one wider value: its common start, then anything. Once a command sets HOME or PWD, ~, cd alone and ~+ follow it. An unquoted value is split at the blanks and at each value the command gives IFS. A list ("$@", "${a[@]}", a slice of one, after shift) keeps its order, so set -- cd /; "$@" moves the shell; one whose order is lost (an element assigned or unset alone) is refused as a command. ${!X} and a nameref (declare -n) name the variable they point to. A transformation of a literal value (${X#…}, ${X%…}, ${X/…/…}, ${X^^}, ${X,,}, ${X:1:2}, ${X@E}, …) is computed as bash computes it; of a glob or a value only known at run time, it is not known, and of the shell's directory, it can be anywhere. An assignment expands ~ after = and each :; += appends to each value (in a loop, to any number of them); declare -l and -u change the case; read and mapfile fed a here-string or a here-document take its fields and lines, $_ is the last word of a command, and ${X@P}, PS4 and the other prompts are read as bash expands a prompt: its backslash escapes are replaced first (an octal one can make a $), then it is expanded as inside double quotes, with a " as a character, and its command substitutions are read. BASH_ALIASES elements are aliases, command_not_found_handle runs wherever the command was, and env 'BASH_FUNC_f%%=() {…}' defines a function. A value longer than 4096 characters, a pattern longer than 1024, a transformation past a command's step budget, a variable given more than 16 values, or expansions nested more than 8 deep is not listed: as a path it can be anywhere, and as a command or a command string it is refused. This covers the command itself and every command it runs: substitutions, subshells, functions (where they are called), aliases, trap, eval, bash -c and bash -s, find -exec (where {} is any file the find reaches) and find -delete, xargs, env, sudo, timeout, flock -c, script -c, the git -c settings that run a command, a shell fed a here-document, and a script a shell reads from its standard input: bash - and sh - after any options, . /dev/stdin <<<, source /proc/self/fd/0, BASH_ENV=/dev/stdin, and an rc file named by --rcfile or --init-file. source, . and eval skip a -- before their operands. A script a shell would read from a command's output (source <(…), BASH_ENV=<(…), bash <(…)) is refused. An argument of any other program that reads as a shell command is read as one too. A cd moves where relative paths resolve, and one that may fail is followed both ways. A relative path is refused after a cd to a directory only known at run time: a variable, a substitution, a glob, -, or a CDPATH lookup. A glob is matched as bash expands it by default, within one path segment and never a hidden name unless the pattern starts with a ., so rm *.log passes and rm .* in the workspace does not. After a shopt, a GLOBIGNORE or a BASHOPTS that may widen globs, a glob is refused wherever it could reach a rule. A text only known at run time inside a word (/workspace/$X) can be any text. A brace expansion is listed up to 64 words, and a longer one is read as a glob. A command embry0 cannot parse is refused, as is one nested more than 40 deep or longer than 64 KiB, and one whose reading costs more than a fixed limit of any kind of work (command nodes, simple commands, expanded words, run text parsed again, pattern steps; embry0/safety/shell_work.py), so a check's time is bounded on any machine. A PreToolUse hook that does not answer before its timeout leaves the tool call unrun: both CLIs refuse it. 64 KiB is also the longest input Ring 3's regular expressions read: a longer one, to a tool a content rule checks, is refused.
  • Skills. The CLI runs the commands in a skill's file (!`…` and ```! blocks) itself when the skill is invoked, with no tool call, and it runs the text it loaded when the session started. So the executor reads every skill in the workspace's .claude/skills and .claude/commands and in the mounted skill sources before the session starts, and a ConfigChange hook refuses every reload of a skill or a settings file during the session. The hook refuses a Skill call in these cases: one of the skill's commands, in the text the session loaded or in its file as it is now, would be refused as a Bash call in the session's directory; the agent may not run Bash; the skill was not read before the session started (one written during it, one in a subdirectory's .claude/skills, and the CLI's own); its file, or a directory from .claude down to it, changed while the session loaded it (a directory renamed away and back, or a symlink flipped and restored, changes its parent's mtime and its own ctime); the file cannot be read or parsed; or triage's request hid every skill (above), so the session loaded none.
  • What they do not catch, by design. Ring 2's shell check is best effort: it reads what the command's text says, and Ring 1 and the egress rules are the controls for what only the running command decides:
    • a path built at run time: a command substitution's output, a value read from a file or a pipe, an IFS only known at run time, arithmetic, a value bash computes as it runs (a match's results, what getopts sets, $* joined by IFS, an associative array's keys, what a variable's attributes make of its value, a file descriptor, a cdable_vars name), or a transformation of such a value, and a .. such a value holds;
    • text bash evaluates as arithmetic or as an array subscript (let, (( )), test -v, declare -i, a subscript in a variable's name), where a variable's value is evaluated in turn;
    • what an interpreter or a program's own script language does (opening a file or running a command), and commands piped into a shell;
    • a command a program takes from its environment or its own configuration (a pager, an SSH command, a repository's git hooks and config, a Makefile);
    • the files an archiver or a downloader writes or reads by its own options, and the paths a program reads from its input;
    • a symlink, whether the repository ships it or the agent makes one: embry0's check normalizes paths lexically and does not resolve symlinks, so a path through a symlink the agent made passes it just as one through a shipped symlink does. The CLI resolves them.
  • Known strictness. A path under a denied directory is refused as the operand of any command not known to treat it as data. So curl --cacert /etc/ssl/…, pip --cert /etc/… and xmllint --xpath /root/… are refused, as are ls /*, find ~ and rm -rf ./$DIR in the workspace. A relative path after cd "$(git rev-parse --show-toplevel)" is refused too: use the absolute path.

Repositories granted Docker access are trusted code. A granted repository's code holds the DinD client certificates, so it controls the daemon that runs every credential proxy (and their tokens) and every other sandbox. The daemon runs privileged, so that is root on the host. Ring 1, the egress drop and sandbox isolation do not bind it. Grant Docker access only to a repository whose code you would run as root on the host.

A per-repo QA cache volume carries what a QA agent writes. Only QA runs of a repository's default branch at its current head, as the forge reports them, use its per-repo volume (cache.shared_volume.scope: per-repo), but the QA agent, which has a shell, runs during those runs: anything it is led to write into .git or the kept dependency directories, such as node_modules or .git/hooks, can persist into later trusted runs until the lockfile changes; every other untracked file is removed before each run. Choose the per-repo scope for applications whose QA-visible content (the pages the agent reads, the data seeded for it) is trusted; the default per-job scope keeps nothing past its job.

The operator tier can run code in a sandbox. A QA config's boot_command runs as a shell command in the sandbox, outside Ring 2 and Ring 3. Package-registry settings such as npm_config_registry and PIP_INDEX_URL stay settable, so installs can come from a registry the operator chooses. And a job's issue text steers the agent. So an operator-tier key or session can read the LLM credential and the repository's secret env values, although the reveal endpoint is admin-only. Give operator access only to people you would trust with those.

A repository's Maven cache is shared by its JVM jobs. Every JVM dev sandbox of a repository mounts that repository's Maven cache (~/.m2) writable, whatever started the job: an issue, a pull request follow-up, an operator's request. So code one job runs can change what a later job of the same repository resolves from the cache: a cached artifact, a plugin, Maven's own settings in it. A cache is never shared across repositories: each repository has its own volume, labelled for it, and a volume whose labels do not name the repository on this deployment is not mounted. A deployment whose instance id is unresolved gives each job a cache of its own instead.

A job can rewrite its own branch. The git-proxy limits a push to the job's branch (ADR 0009), but it cannot tell a fast-forward from a force-push without the object graph, so the job may force-push that branch. For a follow-up job, that branch is the pull request's existing head, which a person may also have pushed to. The forge token's own scope is untouched: anything outside embry0 that holds it can still push anywhere. Keep branch protection on the forge, and a token limited to the repositories embry0 works on.

Isolation is a container on a shared kernel. A kernel or container-runtime escape from a sandbox lands in the DinD container, which is privileged, and so on the host. With AGENT_RUNTIME=rootless it lands in the rootless daemon's account instead: not root on the host, but the owner of every sandbox and proxy, and so of the credentials the proxies hold. A kernel escape is a host compromise either way.

No gVisor under the rootless runtime. Isolation under AGENT_RUNTIME=rootless is the rootless daemon, the capability drop and no-new-privileges, the egress drop and sandbox isolation chains, and per-sandbox limits; every sandbox runs under runc on the host kernel. gVisor (runsc) is refused: under rootless Docker it would run only with --ignore-cgroups, dropping every per-sandbox limit. A profile naming another runtime is refused at creation, and the orchestrator refuses a daemon that registers runsc or defaults to anything but runc. gVisor is revisited for the SaaS runtime (#587). minio-proxy and presign-proxy stay under both runtimes: a sandbox has no other route to MinIO or the presign endpoint, and every alternative would widen its egress.

Code outlives the sandbox unless you set a retention. By default the job's events keep whole files the agent read and wrote and its shell commands, its saved session keeps the whole conversation, and a pre-built QA image keeps a checkout of the repository, all with no end date. JOB_LOG_RETENTION_DAYS, AGENT_SESSION_RETENTION_DAYS and QA_IMAGE_RETENTION_DAYS bound them; registry copies of QA images and database backups stay even then. See Data retention.

Only the dashboard hostname gets the hardened vhost. On the frontend, any hostname other than DASHBOARD_PUBLIC_HOSTNAME gets the LAN vhost: API authentication applies, but the CSP, HSTS and path blocking of the public vhost do not. The webhook tunnel points at that frontend, and its restriction to the webhook paths lives in the tunnel's own configuration on the Cloudflare side, not in embry0 (webhooks.md). Restrict it to the webhook paths, and do not expose port 8200 directly to the internet.

Data retention

Agents work on a repository inside its job's sandbox, and the sandbox is deleted when the job ends. What follows outlives it. The three retention settings are unset by default, which keeps everything marked forever below; see what a value would remove before setting it with GET /api/v1/admin/retention/preview (admin tier, api.md).

What Holds code? Where Kept Bounded by
Job events: tool calls and results, agent text and thinking, costs, nodes Yes: Write, Edit, MultiEdit and NotebookEdit inputs, Bash commands (heredocs write whole files), and every tool result (the file a Read read, Grep matches, cat or git diff output), an input string cut at 65,536 characters and a result at 4,000 job_logs in PostgreSQL forever JOB_LOG_RETENTION_DAYS: for ended jobs, the code fields of events older than that become [removed by job log retention], and each event keeps a redacted record of the tool name, file path, bytes and SHA-256
Saved agent sessions Yes: the whole conversation; for a Claude subscription the CLI's session file, every tool input and result uncapped agent_sessions in PostgreSQL forever AGENT_SESSION_RETENTION_DAYS: ended jobs' sessions last used before then are deleted (a later pull request comment follow-up then starts a fresh session); JOB_LOG_RETENTION_DAYS leaves them
Graph checkpoints Job state checkpoints, checkpoint_blobs, checkpoint_writes until the job ends; a daily sweep removes any left behind
Pre-built QA images Yes: a commit of a builder sandbox after it cloned the repository and installed its dependencies, .git included the sandbox daemon (DinD, or the rootless daemon) forever, including bakes that failed after their commit QA_IMAGE_RETENTION_DAYS: images named embry0-qa-* by a tag or a digest unused for that long, except the one an active qa_image_tags row names and any a container uses. An image left with no tag and no digest (an unpushed bake whose tag a later bake of its repository took in the same minute) names nothing, so it stays
The same images in the registry Yes the in-stack registry (registry-data volume) forever: the registry runs with deletes off and embry0 never deletes from it by hand only
Per-repo warm volumes (cache.shared_volume.scope: per-repo) Yes: a checkout and its installed dependencies. Only QA runs of the default branch at its current head use one, but the QA agent, which has a shell, runs during those runs: anything it is led to write into .git or the kept dependency directories, such as node_modules or .git/hooks, can persist into later trusted runs until the lockfile changes the sandbox daemon forever QA_IMAGE_RETENTION_DAYS: volumes this deployment labelled and no QA job used for that long, except while a job of the repo is live or a container mounts it
Per-repository Maven caches (~/.m2 for a JVM dev sandbox) Yes: that repository's downloaded dependencies the sandbox daemon forever QA_IMAGE_RETENTION_DAYS: a cache this deployment labelled is removed once it is older than that, counted from its creation, except while a job of the repo is live or a container mounts it; the next JVM job of the repo downloads its dependencies again
Per-job QA volumes and sandbox containers Yes, while the job runs the sandbox daemon until the job ends; a paused job's sandbox until PAUSED_JOB_TTL_HOURS (48); the volume reaper sweeps leftovers hourly
QA artifacts: screenshots, videos, traces, logs of the app under test What the app showed and logged MinIO, qa-artifacts bucket QA_ARTIFACT_RETENTION_DAYS (14) that setting
What agents write for people: job summaries, final outputs, review findings, deliverables Prose that can quote fragments of code PostgreSQL (jobs, traces, job_logs text and thinking events, deliverables, pr_reviews) forever
Turbo remote cache (the optional docker-compose.cache.yml overlay) Build outputs turbo_cache_data volume forever not managed by embry0
Database backups Everything above that is in PostgreSQL backup-data volume; the off-host bucket when configured (backup-restore.md) 7 daily, 4 weekly and 6 monthly dumps; the bucket's own lifecycle rule the backup schedule: a dump taken before a redaction keeps what it held

Each sweep runs at startup and then daily and logs its counts (job_log_retention_swept, agent_session_retention_swept, qa_image_retention_swept). The job log and session sweeps touch only jobs that have ended. None removes anything without its setting.

There aren't any published security advisories