Isolation: what the agent and the gate can reach
Cyclix runs a coding agent unattended, and then runs the code that agent wrote in the gate. Both must be kept away from the host’s credentials and from the world outside the run. Part of that is built. Part is coming soon, and the last section lists which.
The problem
Section titled “The problem”Everything the agent reads can carry instructions: the issue, the repository’s files, review comments, web pages. Cyclix limits who can put work on the board, but a file that is already merged can still carry instructions that the agent follows. So the design assumes that an agent can be made to do anything its process is able to do. The question is what that process is able to do.
Today the process is limited in three ways, and it is not contained:
- The tools are limited. Each stage has a list of tools, and the agent cannot use others.
- The environment is limited. The agent and the gate see an allow-list of environment variables, and git and
ghfind no credential in it. - The process is not contained. The agent and the gate still run as the host user, with the real
HOME. Code they run can read any file that user can read. Python started throughBash(uv run *)can read~/.config/gh/hosts.ymlor~/.netrcby its full path. The agent’s Read tool cannot, because--restrictedconfines it to the working directory. The agent can also write files that the engine later runs, such as hooks in the tenant’s clone.
So the merge rule (ADR-0001) holds against an agent that follows its tool list. It does not yet hold against an agent that runs Python to read a token. Run Cyclix as a dedicated user that holds only the tokens the loop needs. If the operator’s GitHub identity opens the PRs and may bypass branch protection, a leaked token can merge a PR or push to the base branch past the rules.
What comparable agent runners do
Section titled “What comparable agent runners do”Anthropic’s cloud sessions of Claude Code, GitHub’s Copilot coding agent and OpenAI’s Codex cloud all follow the same five rules:
- The agent runs inside an isolation boundary: a VM or container separate from the host. The boundary covers the whole agent process, including its file tools, hooks and MCP servers (Model Context Protocol servers, which give the agent extra tools), not only the shell commands it runs.
- No credential that can write to the repository enters the boundary. Anthropic runs git through a proxy that sits outside the VM. Inside, git holds only a scoped credential. The proxy checks each push, allows it only to the session’s own branch, and then adds the real GitHub token. Codex gives secrets only to a setup phase, and removes them before the agent starts. Copilot’s agent can push only to branches whose names start with
copilot/. - Outgoing network traffic is blocked by default. Each runner allows a short list of hosts, such as package indexes. Codex’s agent phase has no internet at all unless the environment turns it on.
- Each task gets a fresh environment, which is thrown away when the task ends.
- GitHub stays the final gate. Branch protection and a required human review apply to everything the agent pushes.
Anthropic’s guidance for running Claude Code unattended says the same. An unattended session belongs in a container, a VM or Anthropic’s sandbox runtime. Claude Code’s built-in /sandbox setting is not enough on its own. It covers shell commands only, while the Read and Edit tools, hooks and MCP servers run outside it.
Sources:
- Claude Code, Choose a sandbox environment and Sandboxing
- Anthropic engineering, Claude Code sandboxing
- Claude Code, Security
- GitHub, Customize the agent firewall and Customize the agent environment
- OpenAI, Codex agent approvals and security and Codex cloud environments
The design
Section titled “The design”Cyclix follows the same five rules (ADR-0005). The engine is the trusted part: it holds the GitHub credential and does every action that reaches GitHub. The agent and the gate are the untrusted part. They get the run’s repository, a Claude login, and nothing else.
What is built
Section titled “What is built”- The agent may use only the tools its stage needs. The engine runs Claude Code with
--permission-mode dontAsk --permission-prompts none, so a tool call outside the stage’s list is refused, and adds--restricted,--strict-mcp-config,--tools,--max-turnsand--max-budget-usd. The plan stage may useRead,GrepandGlob. The build stage may also useEdit,Write, somegitcommands and the tenant’s test commands. The agent command in the config may hold only-p,--printand--effort <level>, and no config value that goes into a command or a path may start with-. The build prompt lists the stage’s tools and asks for one shell command at a time, because a joined command is refused whole if any part of it is off the list.--barestays off, because it needs an API key and not a subscription login. ThedontAskmode with--permission-prompts noneneeds Claude Code 2.1.259 or later. In a test with a subscription login, a build-style call wrote and committed a file. It was refusedls /(which only the repository’s own settings file allowed), a read of/etc/hosts, and a write to.git/hooks/pre-push. The agent still reads the repository’sCLAUDE.md, which is not a settings file. It no longer loads the host user’s own~/.claudesettings, plugins or hooks. - Every agent call has limits on time, turns and cost. Each stage sets
max_turnsandmax_budget_usd, and the agent has a timeout. See Configuration. The event records the turn count beside the cost, so the caps can be checked against real runs. In a small sample of runs, plans took 6 to 10 turns and $0.37 to $0.76, and builds took 6 to 33 turns and $0.23 to $1.82. The caps in the example config are about three times those figures. - The agent and the gate get an allow-list of environment variables, with no credential in it. The lists are in Configuration. Before it hides the user’s git config, the engine runs
git var GIT_AUTHOR_IDENTandgit var GIT_COMMITTER_IDENTin the worktree and passes the names and emails in asGIT_AUTHOR_*andGIT_COMMITTER_*.git varresolves each role the way a commit would: the engine’s ownGIT_*variables first, then the repository’s config,author.*andcommitter.*,user.*, and finallyEMAILand the system’s defaults. So a tenant repository with its ownuser.namekeeps it. - The gate and every git call have time limits, and a call past its limit has its whole process group killed (Configuration).
cyclix checkconfirms that the base branch is protected. It fails unless the base branch requires a pull request and refuses force-pushes and deletion (Commands). It does not yet require an approving review, because the operator’s GitHub identity opens the PRs, and GitHub does not let an author approve their own PR.
1. Coming soon: the agent and the gate run in a container
Section titled “1. Coming soon: the agent and the gate run in a container”Each agent call and each gate run starts a fresh container with rootless Podman, and removes it when the call ends. Rootless means the container runs under the loop’s own Unix user, with no daemon running as root. A process that escapes the container is still only that user.
- What is mounted: the run’s repository, at
/work, writable. Nothing else from the host. The host’s home folder,~/.config/gh,~/.sshand the SSH agent’s socket (SSH_AUTH_SOCK) are absent from the container. - What the image holds: Python,
uv, git, and theclaudecommand, at versions the tenant config pins. The tenant names the image in its config. Cyclix ships a starting image definition for Python repositories. - What the agent’s home folder holds: an empty home, made fresh for each container. Claude Code’s first-start files (
~/.claude.json) are written into it before the agent starts, so the CLI does not wait for a setup step. The agent does not load the host user’s own~/.claudesettings, plugins orCLAUDE.md, so a run behaves the same on any host. - Caches that hold no secrets, such as
uv’s package cache, live in a folder the engine keeps, so the gate does not download every package on every run. The setup step in point 5 mounts the cache writable. The agent and the gate get it read-only.
Why Podman: it runs without a root daemon, works under the systemd user manager that already runs the timer, and uses the standard container image format, so a tenant can build its image with any tool. Docker’s daemon runs as root by default. A full VM for each run (Firecracker, for example) gives a stronger boundary, but it costs much more to set up on one host. Anthropic’s sandbox runtime (@anthropic-ai/sandbox-runtime) wraps a process without containers, but it is a beta research preview, its config format may change, and on Linux it builds its list of protected paths only once, at start.
Podman is a host requirement, like git and gh. It is not a Python dependency, so the rule “standard library only at runtime” still holds (ADR-0004). cyclix check would confirm that Podman is installed and can run a rootless container.
2. Coming soon: the only credential inside is the Claude login
Section titled “2. Coming soon: the only credential inside is the Claude login”The container gets one credential: the Claude login, through CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY. The engine reads it from its own environment and passes that one variable in. The agent can always read its own login. That is unavoidable, and the login allows spending on the Claude account but no change to the repository.
| Credential | Where it lives | Who can use it |
|---|---|---|
GitHub token (gh login) |
The host user’s ~/.config/gh, or GH_TOKEN in the engine’s environment |
The engine only |
| Git push credentials | The engine’s own repository and the gh login |
The engine only |
| SSH keys and the SSH agent | The host | Nobody in the loop |
| Claude login | The engine’s environment, passed into the container | The engine, the agent |
The gate gets no credential at all, the Claude login included.
3. Coming soon: a fresh clone for each run
Section titled “3. Coming soon: a fresh clone for each run”Today each run is a git worktree of one clone for each tenant. A worktree’s .git is a file that points back into the main clone’s .git folder, so mounting the worktree alone does not work, and mounting the main clone would hand the agent every other run’s branches and the shared git config.
Each run would instead get its own clone, made by the engine from the engine’s tenant clone with git clone --no-hardlinks. A hard-linked clone would share object files with the tenant clone, and an agent that rewrote one would corrupt the engine’s own repository. The run’s clone has no remote that can push: its origin is removed before the container starts. The run’s branch is checked out in it, from the remote base or from the branch an earlier run left, as today.
4. Coming soon: the engine pushes from a repository the agent never touched
Section titled “4. Coming soon: the engine pushes from a repository the agent never touched”The agent can write anything inside the run’s clone, including its .git folder. Git runs some of what it finds there: hooks in .git/hooks, and config keys such as core.hooksPath, core.sshCommand, core.fsmonitor and credential.helper. If the engine ran git push in the run’s clone, it would run that code on the host, with the GitHub credential.
So the engine would never run git in a directory the agent could write. It would not fetch from the run’s clone either: fetching from a local repository runs git upload-pack inside it, and git’s own docs warn against doing that with a repository you do not trust. The handoff is a bundle, a single file that holds commits as plain data:
- After the build, a fresh container (not the agent’s) runs
git bundle createfor the run branch in the run’s clone, and writes the file to a folder the engine reads. - The engine runs
git bundle verifyon the file, then fetches the branch from it into the tenant clone that the engine keeps, with-c core.hooksPath=/dev/nullandGIT_CONFIG_GLOBAL=/dev/null. Fetching from a bundle file runs nothing from the run’s clone. - The engine checks what arrived: the commit descends from the expected base, the branch name is the run’s own, and no commit adds a git submodule.
- The engine pushes that one branch to GitHub, without force, from the tenant clone.
The container in step 1 runs git in the run’s clone, so any hook or config the agent planted runs there. That container holds no credential and has no network, so nothing it runs can reach further. The engine reads the head SHA from the fetched commits, not from the run’s clone. Until the agent is contained, this adds nothing, so it waits for the container.
5. Coming soon: outgoing traffic is blocked unless the host is listed
Section titled “5. Coming soon: outgoing traffic is blocked unless the host is listed”- The gate runs with no network (
--network=none). Its dependencies are installed first, in a separate setup step that runs the tenant’s setup command (uv sync --frozenfor a Python repository) with network access to the package index and no credentials. This copies Codex’s two phases. - The agent needs Anthropic’s API. Its container has no direct route out. Its only route is a forward proxy that the engine runs on the host, which allows a listed set of hosts and refuses the rest:
api.anthropic.com, plusclaude.aiandplatform.claude.comwhen the login is a subscription token, plus the package index. Claude Code honoursHTTPS_PROXY, and so douvandpip.
6. A narrow GitHub identity for the engine
Section titled “6. A narrow GitHub identity for the engine”This part is GitHub settings that the operator changes, not code. It narrows the engine’s GitHub credential so that even a leaked token cannot change the base branch:
- A separate identity for the loop: a GitHub App, or a fine-grained token on a machine user. It may write only to the tenant repository, with contents and pull-request rights and no admin rights.
- A ruleset that lets that identity push only to branches matching
cyclix/*. - Branch protection on the base branch that requires one approving review. This becomes possible once the loop’s PRs are not opened under the operator’s own identity, because GitHub does not let an author approve their own PR.
Coming soon: cyclix check would also confirm that the loop’s identity has no admin rights and that the review is required. The check that exists reads a rule whether it comes from classic branch protection or from a ruleset. Reading classic protection needs admin rights, which the narrow identity will not have, so a ruleset that holds every rule is enough on its own. When neither source can be read, the check fails and says so, because it cannot confirm the branch is protected.
Logging in with a subscription token
Section titled “Logging in with a subscription token”The design works with a Claude subscription login as well as with an API key. The subscription login reaches the agent as CLAUDE_CODE_OAUTH_TOKEN, made once with claude setup-token. Things to know:
- Make the token on a machine with a browser, then put it in the environment file the timer loads. Tokens made over SSH inside a container have been reported as rejected.
- Nothing else may compete with the token.
ANTHROPIC_API_KEYmust not be set beside it. With the container, the empty home means no stale~/.claude/.credentials.jsoncan override it. - The token expires, and does not renew itself. Anthropic does not document its lifetime. Community reports say about a year. When it expires, every agent call fails.
- Usage limits are shared. The token draws on the same subscription as the operator’s own use of Claude, so a busy day of interactive work can leave the loop at its limit, and the loop’s runs can do the same to the operator.
--bareis unavailable, because it needs an API key. The container is what would make that acceptable.- The terms are the subscription’s terms.
claude setup-tokenexists for running Claude Code headless, such as in CI. A tenant runs Cyclix under its own login and its own plan.
Coming soon, with the container:
- A login that Claude Code keeps in a file under
~/.claudecannot be passed into the container, because the container gets no host files. A host that logs in that way has to move to a token. - The proxy must allow
claude.aiandplatform.claude.comas well asapi.anthropic.com. Claude Code’s docs list them as needed for OAuth sign-in and token refresh. A run with an API key can leave them out. - The engine would report a failed login as its own reason, distinct from an agent error, so the run does not look like a bad build.
What this design does not stop
Section titled “What this design does not stop”- Sending out what the agent can read. The agent can read the repository, and its traffic to Anthropic’s API is allowed. Code it can read can leave through that route. A private repository’s code is sent to the model provider in any case.
- Bad code in a PR. The agent can write harmful code on its own branch. The gate, a person’s review and branch protection are what stop it from reaching the base branch.
- Spending. The agent holds the Claude login, so it can spend on that account up to the caps.
- A kernel escape. A container shares the host’s kernel. A flaw in the kernel or in Podman could let a process out, as the loop’s own user. A VM for each run would close this, at a cost the single-host setup does not justify.
What is built and what is coming soon
Section titled “What is built and what is coming soon”| Part | State |
|---|---|
Tool lists, dontAsk mode, --restricted and --strict-mcp-config |
Built |
| Turn, cost and time caps, and the turn count in the event | Built |
| Environment allow-list for the agent and the gate | Built |
| Time limits on the gate and on git, SIGINT before SIGKILL, and stream-json output | Built |
cyclix check confirms that the base branch is protected |
Built |
| The container for the agent and the gate (point 1) | Coming soon |
| The Claude login as the only credential inside (point 2) | Coming soon |
| A fresh clone for each run (point 3) | Coming soon |
| The push through a bundle (point 4) | Coming soon |
| The network rules and the proxy (point 5) | Coming soon |
| The narrow GitHub identity, the ruleset and the required review (point 6) | The operator’s GitHub settings. The check for them is coming soon. |
Still to design
Section titled “Still to design”These belong to the container and are decided when it is built:
- How the agent’s container gets no route out except the proxy: a Podman internal network with the proxy as its one member, or a network namespace the proxy shares.
- Who builds and updates the container image, and how its versions are pinned.
- How a developer works on macOS, where rootless Podman runs inside a VM.