Isolation
By default setup, the agent and the checks run as you:
isolation: user # default: none; or containernone: as you. Safety's measures keep credentials out of the agent's way, not its reach (Not a sandbox).user: asawaywork, a user with nothing of yours, which you make once on Linux.container: a fresh container of the repo's image per command: In a container.
doctor says how to make the user, here for a user runner (uid and gid 1001):
✖ isolation: user, but can't run as the awaywork user (sudo: unknown user awaywork); make the worker user once:
sudo useradd --no-create-home --user-group --shell /usr/sbin/nologin awaywork
echo awaywork | sudo tee -a /etc/cron.deny /etc/at.deny
sudo visudo -f /etc/sudoers.d/awaywork # with these two lines:
runner ALL=(awaywork) NOPASSWD:SETENV: ALL
runner ALL=(root) NOPASSWD: /usr/bin/chown -R -h -P awaywork\: /tmp/awaywork-[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9], /usr/bin/chown -R -h -P 1001\:1001 /tmp/awaywork-[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9]No home, no login shell: each clone gets a fresh HOME. The first sudoers line runs anything as the worker; the second only the two chowns that hand it a clone and its HOME and take them back (why that's safe). Then doctor says ✔ isolation: user: setup, the agent and the checks run as awaywork. What changes:
- Logins. Your keychain login isn't there: give Claude a
CLAUDE_CODE_OAUTH_TOKEN(claude setup-token) in the env file;doctorwarns until you do. Cursor needsCURSOR_API_KEYanyway. Codex borrows yourauth.jsonfor the run and its refreshed tokens are saved back, under Codex's lock; read what that doesn't close first. - Tools must be system-wide: the worker can't run what nvm,
~/.local/binor~/.cargoput in your home, anddoctorwarns about each suchPATHentry. For Cursor:sudo env HOME=/opt/cursor bash -c 'curl https://cursor.com/install -fsS | bash', then linkagentinto/usr/local/bin. - Your home must be closed (
chmod o-rwx ~);doctorwarns until it is. - Linux only:
isolation: user runs on Linux only, not darwin. - Always on a GitHub-hosted runner and in awaywork's image, whatever the config says; the action or the Dockerfile makes the user.
- One developer per worker user: the
chownline reaches anyone's awaywork clones.
In a container
isolation: container
repos:
acme/api:
image: ghcr.io/acme/api-dev:latest # what setup, the agent and the checks run in
setup: pnpm i --frozen-lockfileThe one for your Mac, and for more than a user hides on Linux. Every setup, agent run and check starts a fresh container of the repo's image (Docker Desktop, Colima or Linux's engine). It mounts the clone, at its own path, and the skills plugin read-only, nothing else: no home, no docker socket. It runs as your uid and gid, every capability dropped, no-new-privileges. doctor checks Docker, each image (pulling it if missing, failing if it can't) and the agents' CLIs in it:
✔ isolation: container: setup, the agent and the checks run in a fresh container of the repo's image (Docker 27.4.0)
✔ isolation: acme/leak: awaywork-leak has claude 2.1.290 (Claude Code stand-in), codex codex-cli 0.162.1 (stand-in)The image is yours to build, and all the units get:
- The agent CLIs and toolchain the repo's rules use, and the commands of its stdio MCP servers, which start inside. Not
ghorglab: awaywork's forge calls run outside. Install system-wide, not under/root. awaywork skips theENTRYPOINT. - One per repo: under
container, areposentry withoutimageis an error, and a unit in a repo with no entry is skipped (no image for acme/web: isolation: container needs one in its repos entry). Nothing falls back to you. - Pulled once, by the first
docker runordoctor;docker pullto update. - Only the env it needs: the image's, awaywork's placeholders, git config and prompt path, and that phase's secrets by name, never on argv. Nothing else of yours, ssh agent included.
- A
HOMEin the clone,.away/home, fresh per clone: what setup puts there reaches the chain, then goes. Bake caches into the image. - Logins: Claude needs
CLAUDE_CODE_OAUTH_TOKEN, CursorCURSOR_API_KEY(itsHOMEis.away/agent-home). Codex gets yourauth.json(~/.codex,$CODEX_HOMEorconfig_dir) for the run, copied back after under its probes' lock when it's a refresh of the one lent, since a refresh rotates it (Providers). Probes and--versionchecks run on your machine. - Clones in your home, in the run dir, not
/tmp: the one place Colima and Docker Desktop both share.doctorfails if the engine can't mount it. - Removal: each container is gone before awaywork reads the clone,
docker rm -fon a timeout, the window's end or Ctrl-C. No answer from docker within a minute, and the clone is lost.awaywork replayruns in one too. - Cost: an image per repo to keep current, a few hundred ms per command on Colima, slow file sharing on macOS, and Linux only: no Xcode.
Not a sandbox
With isolation: none, the default on your machine and a self-hosted runner, all of the above keeps credentials out of the agent's way, not its reach. It runs as you, and its tools aren't sandboxed: Claude Code has Bash and file tools pre-approved, Codex runs --sandbox danger-full-access (it needs it to commit), Cursor any shell command with read and write (no --force, no sandbox), a command provider whatever your command does. So what it reads can leave [I, from the code]:
~/.config/awaywork/env, with yourGH_TOKENand tracker tokens.- gh's login, by unsetting the placeholders (an empty
GH_CONFIG_DIRdoesn't hide a keyring fromgh auth token);glab's keyring login needs nothing unset. Git credential helpers too: the worker's config names none, but your~/.gitconfigand the system one are readable, and a helper answers when run directly. - twg's login in
~/.config/twg: the emptyTWG_CONFIG_DIRonly points twg elsewhere. Its encrypted storage, keyed from your OS keychain, is untried with the empty dir;doctorwarns. - Your ssh keys and agent: no
SSH_AUTH_SOCK, but the socket can be found (launchctl getenv SSH_AUTH_SOCKon macOS). On Bitbucket those keys push, so a worker reading~/.sshcan push as you. - A VS Code terminal's git askpass (
VSCODE_GIT_*). - On Linux, awaywork's own env under
/proc,GH_TOKENincluded: on a self-hosted runner, your personal access token.
Also open:
- Prompt injection. Strangers' GitHub comments are hidden, but issue bodies, CI logs, check names and links, other sources' text and trusted people's quotes may be anyone's. The harness calls them data; a model can still be talked into things, with the reach above.
- Hooks across a loop's rounds: on your repo, Claude Code loads the clone's
.claude/settings.json, so a round can leave hooks the next runs. - MCP secrets in Claude's Bash and Cursor's shell, on your items (MCP servers; fix in #155).
- Codex and Cursor read a PR author's
AGENTS.md, Cursor alsoCLAUDE.md,.cursor/rulesand skills. - A command provider's repo config on someone else's PR, e.g. MCP servers or hooks in its config dir.
So run awaywork where that's acceptable, or give it a worker user on Linux or a container wherever Docker runs. GitHub's hosted runners always get a worker user, as does awaywork's image.
A worker user on your Linux machine
With isolation: user, setup, the agent and the checks run as awaywork, a user you make once. As on a hosted runner, the clone is the worker's only while repo code runs, awaywork kills all the worker's processes after each phase, and its own git runs only on a clone that's yours again. Without the user or the sudoers lines, run refuses rather than run as you. It closes:
- Your files: the env file, gh's
hosts.yml,~/.ssh,~/.gitconfigand the rest of a home closed to others (chmod o-rwx ~;doctorwarns until then). - Logins in other processes:
/proc/<pid>/environis owner-only, your ssh agent's socket sits in a folder only you can enter, and gh's keyring answers over your session bus in/run/user/<uid>, closed to other users [C, not exercised: the test container has no keyring]. - A way back in: no sudo, cron or
at, anologinshell, so nosshorsu: once its phase is killed, nothing of it runs. - What one unit leaves the next: its
HOMEis a fresh/tmp/awaywork-XXXXXXper clone, removed with it; there's no/home/awaywork. An~/.npmrc, a Claude hook or a shim in~/.local/binnever reaches another repo. - A swapped clone:
/tmpis sticky, so the worker could rename its folder and leave a link to yours. After each phase awaywork checks clone and home are the same folders (lstat, same inode) and closes them (chmod -R u+rwX,go-rwx, top folder711for the next phase; a fresh clone is700). Otherwise the clone is lost: no more phases, no git of awaywork's, nothing published.
A lost clone keeps the worker's .git, config and hooks: run nothing in it, as with replay's; git fetch <clone> <branch> from your checkout runs nothing of it. Tried: a worker that moved its clone or HOME and linked one of the developer's repos there lost the clone, and that repo's .git/config was untouched; chmod 777 on the clone, its .git and ~ left 711, 700 and 711, unwritable to another local user.
A real run in a Debian 12 container, a leak script as the agent, the developer's home at 755. With none it read the env file, the home, gh's hosts.yml and the ssh agent, and left a detached sleep running after the run. With user, after chmod o-rwx ~:
agent: as awaywork (awaywork), HOME=/tmp/awaywork-6etN3Q, cwd=/tmp/awaywork-lLAmqr
agent: ok the env file
agent: ok the developer's home refused
agent: ok awaywork's env /proc/10571/environ
agent: ok any /proc/*/environ
agent: ok gh's hosts.yml
agent: ok the ssh agent at /tmp/ssh-*/agent.*
agent: ok sudo -n true refused
agent: left a detached sleep 4242 behindNothing of the worker's was left running, nor any folder. (At 755, the home and hosts.yml still leaked.)
The sudoers lines give your user no more than that. The first runs anything as the worker, which can do nothing you can't. The second is all awaywork runs as root, chown -R of a clone to the worker and back; a loose rule there would let anything running as you chown /etc/shadow to the worker. So:
- No
*: sudoers'*matches spaces, so/tmp/awaywork-*also allowed/tmp/awaywork-x /etc/hostname, which chowned it. Six character classes match six letters or digits, on any sudo. - Straight under
/tmp: a deeper rule loses to a link; with/tmp/awaywork-??????/work/*allowed, awork -> /etclink chowned/etc/passwd. - No link followed:
-h -Pchanges links themselves, anddoctorandrunrefuse a kernel withfs.protected_hardlinksoff. - No env: the root line has no
SETENV, so sudo refuses--preserve-env=PATHand dropsLD_PRELOAD.
Tried with only those two lines (sudo 1.9.13p3):
- Everything but the exact two commands asked for a password:
/etc/shadow, a seventh character,../, a space, a second path,-L,root:,-u root,-g root. - The same words split differently passed sudo, and
chownrefused them. - Through links to
/etcand/etc/shadow, only the links changed;ln /etc/shadowfailed. - 30 rounds of a loop swapping folders for links to
/etcchanged nothing there. - No file under
/etcended up anyone's but root's.
Still open:
- The network.
- The worker's own login, in its env as it must be:
CLAUDE_CODE_OAUTH_TOKEN,CURSOR_API_KEY, or a copy of your Codexauth.jsonin itsHOME(the lent Codex login). /tmp: every unit is the same user, so a file left in/tmpor/var/tmpoutside its folders is there for a later unit. Nothing runs it unless something looks.- The machine: localhost services answer it, and anything world-writable is a place to plant a binary you run later. Hosted runners clear that bit; on your machine
find / -xdev -perm -o+w ! -type l ! -perm -1000lists them. - Other developers: the
chownline reaches any awaywork clone, so one developer per worker user.
The lent Codex login
awaywork has no Codex login of its own and lends the file your codex uses ($CODEX_HOME/auth.json, env file included, else ~/.codex/auth.json). Since a refresh rotates it, awaywork saves back only a refresh (tokens and last_refresh), refused when the mode, API key or account differs, and only while yours is still the one lent.
The tokens are the worker's word, though: a hostile worker can return another account's, labelled with your account_id, and your Codex then runs on that account, awaywork's runs and your own sessions, prompts and code included, until you codex login again. Codex being experimental, that ships as is; #315 tracks a login it can't swap.
A container on your machine
With isolation: container, every setup, agent run and check runs in a fresh container of the repo's image that sees the clone and the skills plugin and nothing else. That's not a list of things to hide, so it closes more, on a Mac too:
- Your files: only the clone in your home is mounted: no env file,
hosts.yml,~/.ssh,~/.gitconfigor keychain files, and nosecurityto ask the keychain. - Other processes: its own
/proc, never awaywork's env. Neither the ssh agent's socket nor the docker socket is mounted, so no container of its own with your disk. - Root inside: your uid and gid, no capabilities,
no-new-privileges, so a setuidsudogoes nowhere, even withNOPASSWD: ALL. Setuid files it makes are yours and Colima keeps the bit, so awaywork drops it when copying.awayout, and the run dir is700. - Your env: the image's, awaywork's placeholders and git config, and that phase's secrets by name (
-e NAME, never on argv), nothing else of yours. - Nothing outlives its phase: a container goes with its command, and awaywork
docker rm -fs it on a timeout, the window's end, Ctrl-C or the watchdog. If one may still be running, listed or docker silent for a minute, the clone is lost, as with the worker user: nothing is published from it, awaywork leaves it alone and saves no Codex login back from it. A lost clone may still have a container: run nothing in it.
A real run on a Mac (Colima 0.8.1, Docker 27.4.0), a leak script as setup, when check and Claude stand-in, in an image granting NOPASSWD: ALL:
agent: as dev (501:20), HOME=~/.local/state/awaywork/runs/20261021-003248/work/leak-1/.away/home
agent: ok the env file
agent: ok your home holds only the folder on the clone's path
agent: ok ~/.ssh
agent: ok the keychain (no security, no ~/Library/Keychains)
agent: ok no ssh agent socket
agent: ok /proc/*/environ
agent: ok no docker socket found
agent: info mounts from the host: …/work/leak-1 (rw) …/plugins/leak-1 (ro)
agent: ok sudo -n true refused (the image allows everyone NOPASSWD: ALL)
agent: ok git credential fill has nothing
[...]
agent: left a detached sleep 4242 behindNo container or sleep was left; an agent hung past unit_minutes: 1 was stopped at 64 s. Still open:
- The network: what it reads, it can send. Colima's gateway didn't reach a Mac
localhostservice here, but your LAN is reachable, and Docker Desktop'shost.docker.internalis your Mac. Domain allowlists: #316. - The agent's secrets: its token, Codex's
auth.jsonfor the run, its MCP servers' secrets. The Codex login it hands back has the worker user's limits. - The clone: what a phase leaves is what checks run and awaywork publishes, though awaywork restores git config and refuses links after each phase.
- The image: a token baked into a layer or
.npmrcreaches every unit. - The kernel: a container shares the VM's kernel, on Linux your machine's: it's a namespace, not a VM, and a kernel bug is a way out. On a Mac that lands in the VM, and Colima mounts your whole home read-write (
colima ssh -- mountshows/Users/<you>over sshfs,rw), so the VM is no second wall. - Memory on a Mac: the watchdog watches your Mac, not the VM, whose OOM killer can take
dockerdand lose the clone. The disk floor holds.
A worker user on GitHub-hosted runners
A hosted runner is where your classic token meets issue text unattended, with passwordless sudo, so repo code never runs as you there. Setup, the agent and the checks run as awaywork, made by the action, always:
- A user with nothing: no sudo,
docker, other groups or crontab. - A locked runner: the runner's home is
chmod 700, with your checkout, step scripts,RUNNER_TEMPandGITHUB_ENV(the env of later steps). - Nothing world-writable: the action clears the bit on some 800,000 paths (
/usr/local/binand the toolcache'snodeamong them, where the worker could plant aghawaywork runs with your token) and on the default ACLs of some 36,000 folders. Under two minutes, mostly disk reads. - A safe kernel: it refuses one with
fs.protected_hardlinksorfs.protected_symlinksoff (both on in GitHub's image). - Only the clone is shared: clones, per-clone
HOMEs and the plugin live in/tmp. A clone is the worker's while repo code runs, then the runner's again, checked to be the same folder and closed, before awaywork's git touches it. - awaywork's git uses the clone's own
.gitonly, never a repo above it in/tmp, and only once its config is awaywork's again. A.gitturned into a link, with a symlink outside its hooks, or borrowing another repo's objects gets no more git and publishes nothing. A clone the runner can't take back (a failingchown) ends its chain unpublished and untouched. - Nothing outlives its phase: awaywork kills all the worker's processes after each one.
- No value on argv: env goes through sudo by name (
sudo --preserve-env=PATH,CLAUDE_CODE_OAUTH_TOKEN,…), and pushes use gh's credential helper, never a token in a URL. - No silent fallback: without the user, say
awaywork runwithout the action, it refuses:can't run as the awaywork user on this GitHub-hosted runner: run awaywork through its action, which creates it.
A spike tried every leak above from setup, a when check and two agent runs, with GITHUB_TOKEN as GH_TOKEN. As runner, all of them worked: awaywork's /proc env, RUNNER_TEMP, GITHUB_ENV, sudo, docker, crontab, planting in the toolcache, a leftover process. As the worker user:
agent: as awaywork (1 groups: awaywork), HOME=/home/awaywork
agent: ok awaywork's env /proc/2890/environ
agent: ok any /proc/*/cmdline
agent: ok gh auth token, GH_TOKEN and GH_CONFIG_DIR unset
agent: ok RUNNER_TEMP refused
agent: ok GITHUB_ENV not writable
agent: ok sudo -n true refused
agent: ok docker refused
agent: ok crontab refused
agent: ok no PATH or toolchain dir writable
agent: ok nothing left from an earlier phaseThat spike predates per-clone HOMEs, now a /tmp/awaywork-XXXXXX. Still open: the agent's own secrets (its token, its MCP servers'), the network, the clone, what's world-readable (toolchains, /tmp, every command line but not env), and the disk and memory, where the watchdog stops the unit.
A worker user in awaywork's image
The container for awaywork watch is built from awaywork's Dockerfile, which sets AWAYWORK_IMAGE=1: setup, the agent and the checks always run as awaywork, whatever isolation says. awaywork runs as away, whose env holds GH_TOKEN, so an agent running as away could read it from /proc/1/environ. Another image without the user refuses: can't run as the awaywork user in this image: build it from awaywork's Dockerfile, which creates it.
At build, the Dockerfile makes the worker as the action does: no sudo, cron or at, nologin, no home, /home/away 700, world-writable bits and default ACLs cleared. away gets the two isolation: user sudoers lines, kept equal by a test. The kernel settings are the host's, checked at the daemon's first run. A stand-in agent:
agent: as awaywork (awaywork), HOME=/tmp/awaywork-W8BnHa
agent: ok /proc/1/environ refused (/usr/bin/tini -- /usr/local/bin/awaywork)
agent: ok /home/away refused
agent: ok sudo -n true refused
agent: GH_TOKEN=none
agent: committed smoke.txtIt closes and leaves open what the local worker user does. On a shared box:
- One container per developer: everything is in one
GH_TOKEN's name, and thechownline is for one developer. A company box runs a container, control repo and token per person. - Host root reads every token:
docker inspectshows env, so a Coolify admin or anyone withdockerhas them all. Put your token only where you'd trust root. - Never mount the Docker socket: whatever reaches it is root on the host, and every container's tokens with it.