# Isolating agent runs on a device

Source: https://codeherder.com/docs/agent-isolation/

What an agent running on your device can reach, and the shipped ways to tighten it.

Every task gets its own sandbox — an isolated git worktree and branch, so parallel tasks never collide with each other (see [Core concepts](https://codeherder.com/docs/concepts/)). That isolates tasks from *each other*. It says nothing about what an agent can reach on the *device itself* — your home directory, SSH keys, other checkouts on the same machine. This page covers that boundary: what’s exposed by default, and the three ways to shrink it.

## The default posture

Unless you set up one of the options below, an agent stage runs as a plain host process — the same OS user that started `ch device-server` — with its sandbox’s worktree as its working directory. It can read and write anything that user can: your home directory, credentials, other repositories checked out on the same box, all of it. Nothing about the sandbox model restricts this; the worktree isolates tasks from each other, not the agent from the device.

## What an agent can reach on the device

This section lists what an agent can reach in each mode. Pooled AI credentials are the sensitive part.

Every mode: the agent holds its own session’s Claude credential in its environment. It needs that credential to call Claude.

**No agent user and no per-stage container.** The agent runs as the same user as `ch device-server`. It can read every pooled AI credential and the `default` Claude token under `~/.codeherder/credentials`. It can also call `ch device credential ...`, `ch device claude-token set` and `ch device git-token ...` through the device-local socket. Those calls add, disable and remove credentials.

**`--docker` (Option 3).** The same holds inside the container. The server and the agent share one user there.

**`CH_AGENT_USER` (Option 1).** The agent cannot read those files, even when it shares the `ch-agents` group. The credential directories are mode 0700 and the files are 0600. The device-local socket also refuses the agent: it accepts only the device server’s own user. Two tests cover this: `TestAgentUser_CannotReadPooledCredentials` and `TestAgentUser_RefusedByCredentialVerbs` in `internal/devicetunnelclient`.

**`--docker-executor` (Option 2).** The stage container mounts only the task’s worktree, the session’s own bearer, and a few read-only helper files. The credential pool and the device-local socket are outside the container.

One credential pool serves every workspace on a device. See [What a shared device shares](https://codeherder.com/docs/self-host-device-isolation/#what-a-shared-device-shares).

## What you get with no setup: push isolation on non-writable stages

One protection applies out of the box, on every device, in every execution mode: the push credential leaves the stage’s environment. It does not hide the token pool or the `gh` / `glab` login files. Without a dedicated agent user, a stage can still read those. A stage whose **Writable** switch is off — typically planning and review stages — has the push credential stripped from its own environment: it can commit locally, but the stage process itself isn’t handed a way to push to your shared remote. Writable stages, the ones that actually ship code, get the credential. See **Per-stage settings** under [Customising workflows](https://codeherder.com/docs/task-types/#per-stage-settings) for where that switch lives — the shared library stage for a composed workflow, the type’s own schema for a hand-written one — and what it otherwise controls.

Know the default here, because it’s the permissive one: if a device has no dedicated agent user configured (Option 1 below) and a push credential is still readable by whichever account runs the stage, a non-writable stage runs anyway — with the credential stripped from its own environment, but without refusing to start. To make this fail closed instead — refuse to run a non-writable stage at all while a push credential is reachable and no dedicated agent user is configured — set `CH_ALLOW_UNISOLATED_PLANNING=0` in the device server’s environment, or in the device’s **Settings** section (see [Changing a device’s settings](https://codeherder.com/docs/device-settings/)). It accepts the same values as other CodeHerder boolean settings: `1` / `true` / `yes` / `on` and `0` / `false` / `no` / `off`.

## Option 1: a dedicated agent OS user

The lightest way to separate agents from your own account. Run:

```
ch device setup-agent-user | sudo sh
```

`setup-agent-user` only prints a shell script — it never runs anything itself; piping it to `sudo sh` is what applies it. It supports Linux and macOS (any other OS refuses with an unsupported-OS error).

Flags:

- `--user <name>` — the agent account to create (default `ch-agent`).
- `--group <name>` — a shared group used to grant the setup (default `ch-agents`).
- `--ch-path <path>` — path to the `ch` binary the sudoers rule may run (defaults to the one you’re currently running).

The generated script creates a low-privilege, no-login system user and the shared group, adds your own account to that group, and installs a passwordless sudo rule that lets the device server start agent processes as that user — nothing broader.

Finish the setup (the script prints these same steps when you run it):

1. Log out and back in — or restart `ch device-server` — so your new group membership takes effect.
2. Set `CH_AGENT_USER=ch-agent` (or whatever you passed to `--user`) in the device server’s environment. If you also passed a non-default `--group`, set `CH_AGENT_GROUP` to match.
3. Restart `ch device-server`.

Once configured, every agent stage runs as that dedicated user instead of your own account — it cannot read your home directory, your SSH keys, or anything else scoped to you. See [Running the device server as a service](https://codeherder.com/docs/running-the-device-server/) for where to add an environment variable under systemd or launchd.

This option satisfies the repository credential gate. Every session shares the one agent user, so it is not a boundary between sessions. `CH_REQUIRE_ISOLATED_EXECUTION` refuses it.

## Option 2: a fresh container per stage

```
ch device-server --docker-executor
```

Environment equivalent: `CH_DOCKER_EXECUTOR=1`. Off by default — a device runs stages as host processes unless you opt in. Requires Docker installed and running on the device; the stage image is fetched automatically using the device’s own CodeHerder credential, so there’s nothing to build or configure yourself.

With this on, every stage runs in its own container, created fresh and destroyed the moment the stage ends. Only the task’s sandbox — its worktree and the git data it needs — is mounted in; your home directory, SSH keys, and the rest of the machine are not reachable from inside.

Hardening applied to every stage’s container:

- Runs as a non-root user.
- Every extra Linux capability is dropped, and privilege escalation is disabled (`no-new-privileges`).
- CPU (2 cores) and process count (512) capped by default; memory is uncapped by default (the container may use the whole host) — set `CH_STAGE_MEMORY` (e.g. `12g`) to cap it, or `CH_STAGE_CPUS` to change the CPU cap.
- The container’s own filesystem is writable — that’s separate from the **Writable** switch above, which governs whether the stage can push, not what it can write to disk.

Outbound network is filtered on by default. Each stage gets its own proxy on its own internal Docker network. A writable stage runs in strict mode by default: the proxy allows only a list of hosts on approved ports (443 and 80). The list covers the GitHub and GitLab hosts, Anthropic, npm and Node, the Go module proxy, PyPI, crates.io and your CodeHerder server. Set `EGRESS_WRITABLE_MODE` to change this. A read-only stage keeps `EGRESS_MODE`, which is open by default: the public internet is reachable over HTTP and HTTPS. In both modes, requests to private network ranges, loopback and link-local addresses, and cloud-metadata endpoints are refused, which is what stops a stage reaching your internal network. A blocked destination can become a request that a person approves; see [Network access approvals](https://codeherder.com/docs/egress-approvals/). `--stage-egress=false` turns the filtering off entirely, including those refusals — the stage container isn’t placed on the internal network at all, so private ranges, loopback, and cloud metadata are reachable again. There is no environment-variable equivalent for this flag. Without filtering, the stage container is not a boundary against a hostile agent, so the device server refuses to start with `--stage-egress=false` unless you also set `CH_TRUSTED_EXECUTION=1`. The same consent is required for `CH_STAGE_ALLOW_ROOT_IMAGE=1`, which lets a stage run as root.

`--docker-executor` and `--docker` (below) are mutually exclusive — passing both is a startup error. `--docker` puts the whole device server inside one container; `--docker-executor` puts each stage in its own. Pick one.

Once enabled, the device’s Health snapshot grows a **Docker ready** check that confirms Docker is reachable and the stage image is available — it blocks new work on that device if it fails. See **Device health and readiness checks** in [Managing your devices](https://codeherder.com/docs/devices/#device-health-and-readiness-checks). Live terminals and dropping into a running session work the same way in this mode — see [Following a live agent session](https://codeherder.com/docs/following-a-live-session/).

A stage doesn’t have to run the device’s default image, either — see [Running a stage in your own container image](https://codeherder.com/docs/stage-images/) for naming your own image per stage. That page’s setup script (`before_script`) isn’t specific to this option. It runs by default under `--docker-executor` and under `ch device-server --docker`. In direct mode (the device server runs on the machine with no container), the device refuses it unless the operator sets `CH_HOST_BEFORE_SCRIPT`. See [Running a stage in your own container image](https://codeherder.com/docs/stage-images/) for detail.

## Option 3: run the whole device server in a container (trusted mode)

```
CH_TRUSTED_EXECUTION=1 ch device-server --docker
```

This is the trusted mode, because the container is privileged and gives the agent `sudo`. Set `CH_TRUSTED_EXECUTION=1` to consent. Without it, the device server exits at start. Use it only for agents and repositories you trust. For untrusted agents, use Option 2 (`--docker-executor`) with its defaults.

This keeps the device server process — and everything it spawns — away from your host files: your home directory, SSH keys, and other credentials on your machine are not mounted inside the container. It guards against mistakes, not against a hostile agent (see [Known limits of the Docker options](https://codeherder.com/docs/agent-isolation/#known-limits-of-the-docker-options)). It’s coarser than Option 2: one container holds the whole server and every stage it runs, rather than a fresh container per stage. See **Docker container (trusted mode)** in [Running the device server as a service](https://codeherder.com/docs/running-the-device-server/#docker-container-trusted-mode) for setup, requirements, and how it interacts with session storage.

## Known limits of the Docker options

Option 3 runs one privileged container, and agents have passwordless sudo inside it. It keeps your files out of reach of a well-behaved agent. It does not stop a hostile agent reaching the host. Do not rely on it as a security boundary.

Option 2 has a socket proxy. The proxy checks its caller and manages only its own containers. It refuses published ports and unsafe create fields, and it gates writable binds. It does not limit image registries or an empty container user. A stage cannot reach the proxy. This matters only if the device server itself is compromised.

Both limits are accepted.

## Choosing between the options

- Nothing configured: you already have push isolation on non-writable stages (see above) — consider `CH_ALLOW_UNISOLATED_PLANNING=0` if that’s your only protection and you want it to fail closed.
- Want agents separated from your own account with the least setup: Option 1.
- Want each stage to run in its own disposable environment: Option 2.
- Want the entire device server — not just individual stages — walled off from the host: Option 3.

Options 2 and 3 cannot run together. Option 1 addresses a different layer (which OS account an agent runs as) than Options 2 and 3 (containment via container).

## Related guides

- [Managing your devices](https://codeherder.com/docs/devices/) — health checks, readiness, and the **Docker ready** check this page’s Option 2 adds
- [Running the device server as a service](https://codeherder.com/docs/running-the-device-server/) — where to set environment variables under systemd or launchd, and the “Docker container (trusted mode)” option this page’s Option 3 uses
- [Customising workflows](https://codeherder.com/docs/task-types/) — the **Writable** switch that governs push-credential exposure per stage, and where it’s set
- [Following a live agent session](https://codeherder.com/docs/following-a-live-session/) — watching or dropping into a session, unchanged by any of the options above
- [Running a stage in your own container image](https://codeherder.com/docs/stage-images/) — naming a custom image for a stage running under Option 2, and the setup script, which direct mode refuses unless `CH_HOST_BEFORE_SCRIPT` is set
- [Who can run code on your device](https://codeherder.com/docs/device-trust/) — who can make a device run code, and what is recorded
