Skip to content

Isolation ​

By default setup, the agent and the checks run as you:

yaml
isolation: user # default: none; or container
  • none: as you. Safety's measures keep credentials out of the agent's way, not its reach (Not a sandbox).
  • user: as awaywork, 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; doctor warns until you do. Cursor needs CURSOR_API_KEY anyway. Codex borrows your auth.json for 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/bin or ~/.cargo put in your home, and doctor warns about each such PATH entry. For Cursor: sudo env HOME=/opt/cursor bash -c 'curl https://cursor.com/install -fsS | bash', then link agent into /usr/local/bin.
  • Your home must be closed (chmod o-rwx ~); doctor warns 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 chown line reaches anyone's awaywork clones.

In a container ​

yaml
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-lockfile

The 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 gh or glab: awaywork's forge calls run outside. Install system-wide, not under /root. awaywork skips the ENTRYPOINT.
  • One per repo: under container, a repos entry without image is 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 run or doctor; docker pull to 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 HOME in 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, Cursor CURSOR_API_KEY (its HOME is .away/agent-home). Codex gets your auth.json (~/.codex, $CODEX_HOME or config_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 --version checks run on your machine.
  • Clones in your home, in the run dir, not /tmp: the one place Colima and Docker Desktop both share. doctor fails if the engine can't mount it.
  • Removal: each container is gone before awaywork reads the clone, docker rm -f on a timeout, the window's end or Ctrl-C. No answer from docker within a minute, and the clone is lost. awaywork replay runs 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 your GH_TOKEN and tracker tokens.
  • gh's login, by unsetting the placeholders (an empty GH_CONFIG_DIR doesn't hide a keyring from gh auth token); glab's keyring login needs nothing unset. Git credential helpers too: the worker's config names none, but your ~/.gitconfig and the system one are readable, and a helper answers when run directly.
  • twg's login in ~/.config/twg: the empty TWG_CONFIG_DIR only points twg elsewhere. Its encrypted storage, keyed from your OS keychain, is untried with the empty dir; doctor warns.
  • Your ssh keys and agent: no SSH_AUTH_SOCK, but the socket can be found (launchctl getenv SSH_AUTH_SOCK on macOS). On Bitbucket those keys push, so a worker reading ~/.ssh can push as you.
  • A VS Code terminal's git askpass (VSCODE_GIT_*).
  • On Linux, awaywork's own env under /proc, GH_TOKEN included: 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 also CLAUDE.md, .cursor/rules and 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, ~/.gitconfig and the rest of a home closed to others (chmod o-rwx ~; doctor warns until then).
  • Logins in other processes: /proc/<pid>/environ is 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, a nologin shell, so no ssh or su: once its phase is killed, nothing of it runs.
  • What one unit leaves the next: its HOME is a fresh /tmp/awaywork-XXXXXX per clone, removed with it; there's no /home/awaywork. An ~/.npmrc, a Claude hook or a shim in ~/.local/bin never reaches another repo.
  • A swapped clone: /tmp is 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 folder 711 for the next phase; a fresh clone is 700). 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 behind

Nothing 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, a work -> /etc link chowned /etc/passwd.
  • No link followed: -h -P changes links themselves, and doctor and run refuse a kernel with fs.protected_hardlinks off.
  • No env: the root line has no SETENV, so sudo refuses --preserve-env=PATH and drops LD_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 chown refused them.
  • Through links to /etc and /etc/shadow, only the links changed; ln /etc/shadow failed.
  • 30 rounds of a loop swapping folders for links to /etc changed nothing there.
  • No file under /etc ended 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 Codex auth.json in its HOME (the lent Codex login).
  • /tmp: every unit is the same user, so a file left in /tmp or /var/tmp outside 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 -1000 lists them.
  • Other developers: the chown line 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, ~/.gitconfig or keychain files, and no security to 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 setuid sudo goes nowhere, even with NOPASSWD: ALL. Setuid files it makes are yours and Colima keeps the bit, so awaywork drops it when copying .away out, and the run dir is 700.
  • 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 behind

No 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 localhost service here, but your LAN is reachable, and Docker Desktop's host.docker.internal is your Mac. Domain allowlists: #316.
  • The agent's secrets: its token, Codex's auth.json for 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 .npmrc reaches 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 -- mount shows /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 dockerd and 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_TEMP and GITHUB_ENV (the env of later steps).
  • Nothing world-writable: the action clears the bit on some 800,000 paths (/usr/local/bin and the toolcache's node among them, where the worker could plant a gh awaywork 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_hardlinks or fs.protected_symlinks off (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 .git only, never a repo above it in /tmp, and only once its config is awaywork's again. A .git turned 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 failing chown) 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 run without 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 phase

That 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.txt

It 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 the chown line is for one developer. A company box runs a container, control repo and token per person.
  • Host root reads every token: docker inspect shows env, so a Coolify admin or anyone with docker has 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.