Skip to content

Isolation

github-actions[bot] edited this page Sep 22, 2026 · 5 revisions

Agent isolation & sandboxing

Locally-dispatched agents historically ran as the same OS user as the daemon — a sibling agent could read another's process table, files, and fds. The isolation: block closes that: per-dispatch isolation for the runtimes conductor launches itself, plus a conductor-enforced network egress allowlist. (#36 §15.)

Where it applies

isolation: can be set at three scopes:

Scope Applies to Wins
agents.<name>.isolation that profile's launches most specific
runtimes.<name>.isolation every launch of that runtime fallback
hosts.<name>.isolation every script that SSH host runs independent

It only applies to launches conductor performs itself: acp, cli, opencode, and agent-deck runtimes, and hosts: scripts (code steps, remote commands). A paseo runtime's agents are children of the paseo daemon — conductor never holds that process, so conductor validate rejects isolation: on paseo runtimes and on profiles that resolve to one, rather than silently not isolating. Use paseo's own sandboxing there, or move the profile to a runtime conductor launches.

Code-step engines (sandboxed by default)

Connectors and runtimes follow the app-extension model: a plugin you added is trusted, so with no isolation: block it runs under its permission manifest + scrubbed env, not an OS jail. Code-step ENGINES are the exception. A run: js step executes arbitrary code and the engine declares no capabilities, so conductor sandboxes engine plugins by default — a namespace-mode sandbox with the network denied. Tune or opt out per engine with the top-level engines: block, keyed by engine name:

engines:
  js:   {}                                             # default: sandboxed (namespace, network denied)
  lua:  { trust: full }                                # opt OUT — run unconfined (an engine you've audited)
  wasm: { isolation: { mode: user, user: sandbox } }   # explicit override (fail-closed)

The synthesized default is best-effort: where the OS sandbox can't be applied (non-Linux, running as root, or unshare / unprivileged user namespaces unavailable) the engine runs with a loud warning rather than failing — a code engine that worked yesterday keeps working. An explicit isolation: block is fail-closed, like everywhere else. Daemon-path masking is not part of the default (an unprivileged unshare cannot reliably overmount the state/config dirs), so the default leans on the network + pid namespaces — enough to confine a pure-compute engine (no egress, no view of other processes); use an explicit isolation: (or mode: container) if you need filesystem masking too.

Modes

x-templates:
  risky-fixer: &risky-fixer
    type: agent
    runtime: gemini
    isolation:
      mode: user | namespace | container
      user: sandboxagent                  # mode: user
      container: { image: agents:latest, engine: docker }  # mode: container
      limits: { memory: 2g, cpu: 200%, pids: 256 }
      network:
        egress: [ "api.github.com:443", "*.internal:443" ]
        deny: true    # + egress ⇒ ENFORCED allowlist (namespace/container);
                      # alone ⇒ structural no-network; absent ⇒ advisory proxy
      fs: [ "/srv/media" ]  # filesystem allow-list: extra paths the sandbox may
                            # see (namespace = a real pivot_root jail; container
                            # = -v binds). Empty ⇒ just the workdir.
      # privileged: true   # namespace mode: opt back into the daemon's full
      #                    # filesystem view (state/config masked by default)
      # allow_root: true   # namespace mode: run the sandbox even when the
      #                    # daemon is root (NOT a boundary then — see below)

mode: user — a distinct low-privilege user

The launch is wrapped in sudo -n -u <user> --. Works on any Unix; the conductor user needs a sudoers rule like:

conductor ALL=(sandboxagent) NOPASSWD: ALL

The sandbox user should own nothing but its own scratch space; the worktree must be readable/writable by it (group membership or ACLs).

Honest scope — what mode: user does and doesn't give you. It isolates the agent from the daemon (different uid → no reading conductor's state, config, or memory). It does not isolate concurrent dispatches from each other when they share the account: same EUID means a sibling agent can read /proc/<pid>/environ (tokens included) and signal/ptrace its peers. And any egress: allowlist under mode: user is advisory-only — there's no network namespace, so only the proxy env steers traffic; a runtime that ignores HTTP(S)_PROXY reaches the network directly. conductor validate warns on both. For agent-vs-agent isolation give each concurrent scope its own user:, or use namespace/container; for an enforced allowlist use deny: true + egress: under namespace/container.

One combination is refused outright (no override): a skill: profile whose effective isolation is mode: user. The skill's one-shot claim code rides the tool server's environment, and same-EUID siblings can read it from /proc/<pid>/environ and race the claim — re-opening the broker-identity hijack the claim flow exists to close. Use namespace/container (separate /proc views) with skill:, or drop one of the two.

mode: namespace — the OS-native least-privilege jail

mode: namespace is portable: it means "the OS's native least-privilege jail", and conductor picks the backend by OS — Linux user namespaces here, macOS Seatbelt (sandbox-exec) on a Mac. The config surface is identical (network: / fs:), so the same YAML — including the confined-by-default a pack gets — works on both; see #macos-seatbelt below for what differs. The rest of this section describes the Linux backend.

The launch is wrapped in unshare --user --map-current-user --pid --fork --mount-proc --kill-child: its own user/pid/mount namespaces, so it cannot see sibling process tables or /proc/<pid>/environ of other agents. With limits: set, a systemd-run --user --scope prefix applies cgroup caps (MemoryMax, CPUQuota, TasksMax). network: {deny: true} adds --net: the agent has no network interface but loopback in an empty namespace — structural.

Filesystem — two shapes. A namespace keeps the daemon's own uid, so file permissions alone would let the launch read everything the daemon can. Conductor closes that off in one of two ways:

  • CODE steps (use: cli/command:, run: <interpreter>) get a real filesystem JAIL. The launch is re-entered through conductor's own helper, which builds a fresh root, bind-mounts in only the allow-list — the workdir, the interpreter essentials (/usr, /etc, /bin…, read-only), a private /proc + /dev + /tmp, the step's own code/ctx sockets, and any fs: paths you declare — and pivot_roots into it. Everything else on the host, the daemon's state/config/secrets included, is gone by absence (not merely masked). The privilege that lets it mount is dropped before your code runs, so the code cannot pivot back out. This is a bwrap-style jail with no docker required; declare the paths a step legitimately needs with fs:.
  • Runtime/plugin launches (an agent runtime's own process) instead mask the daemon's state and config directories (empty read-only tmpfs over directories, /dev/null over files); privileged: true opts out. Here the rest of the daemon's uid view (its $HOME, other repos) is still visible — for a full jail on a runtime launch, use mode: container or mode: user.

A confinement that can't be applied fails the launch rather than running unconfined — except a pack's synthesized default (below), which degrades with a warning so a pack still runs on a box without namespaces.

isolation: { mode: namespace, privileged: true } is the deliberate opt-in to the daemon's full filesystem view (no masking) — for a trusted profile that genuinely needs the daemon's own files. It's the same philosophy as trust: full: the strong posture is the default, the footgun is explicit. Remote (hosts:) namespace wraps never mask (the helper binary lives on this box) — the remote box's own account setup is the wall there.

Never a boundary as root — refused by default. --map-current-user maps the daemon's own uid into the new user namespace. When the daemon runs as root (euid 0) that mapping is root→root: the sandboxed agent keeps real uid 0 and full CAP_SYS_ADMIN over the host, so the user namespace confers no privilege separation at all — masks, --net, and cgroup limits become things a root agent can simply undo. Conductor therefore refuses a namespace-mode launch when euid is 0 with an error pointing you at a non-root daemon user or mode: container. Run conductor as a dedicated non-root user (the intended posture), or switch that profile to mode: container.

isolation: { mode: namespace, allow_root: true } is the deliberate opt-in to run the namespace sandbox as root anyway — valid only when you are using the namespace for process/mount cleanup or cgroup limits and are not relying on it as a security wall against the agent. Same philosophy as privileged: and trust: full: strong-by-default, footgun explicit. It applies to namespace mode only (validate rejects it elsewhere as a no-op).

The Linux backend needs user namespaces + pivot_root; the macOS backend (below) needs sandbox-exec. On any other platform conductor validate rejects mode: namespace (a remote hosts: entry skips the local check — the remote box's OS applies).

macOS — Seatbelt {#macos-seatbelt}

On a Mac the same mode: namespace is realized by Seatbelt, Apple's kernel sandbox, via sandbox-exec -p <profile> (shipped on every Mac; no root, no Docker). conductor generates a deny-by-default SBPL profile from the SAME allow-list the Linux jail uses:

  • (deny default) → the daemon's config/state/secrets are unreadable (the macOS form of "hidden by absence" — deny-default denies metadata too, so stat is refused, not just reads).
  • each fs: path + the workdir → (allow file-read* file-write* (subpath …)); the step's own code/ctx temp dirs are added read-only/read-write for you.
  • network: {deny: true} → (deny network*); open/advisory → (allow network*) (the advisory HTTP(S)_PROXY still steers a well-behaved runtime).

Two differences from Linux, both enforced at validate:

  • cgroup limits: are ignored on macOS (no systemd/cgroups analog).
  • An enforced egress allowlist (deny: true + egress:) is Linux-only — Seatbelt can cut the network wholesale but not run the in-sandbox forwarder. On macOS use mode: container for an enforced allowlist, or plain deny: true / an advisory egress: without deny.

Seatbelt is a kernel sandbox, not a uid trick, so the "not a boundary as root" caveat does not apply on macOS.

mode: container — docker / podman

The launch becomes docker run --rm -i -v <worktree>:<worktree> -w <worktree> … (engine podman selectable). The image must carry the runtime binary the launch expects (e.g. claude, gemini). deny: true becomes --network=none; limits: map to --memory/--cpus/--pids-limit. The identity env is passed through with -e KEY — the daemon's own environment is not forwarded into the container. Not supported for remote (host:) launches — configure it on that box's own conductor.

The egress allowlist (conductor-enforced)

A network: block routes the launched runtime's HTTP(S) traffic through a loopback forward proxy inside conductor's own process:

  • egress: [ "api.github.com:443", "*.internal", "10.0.0.7:8443" ] — CONNECT tunnels and plain-HTTP proxy requests are matched against the patterns (host, host:port, host:*, glob on the host half). A bare host means :443 only (the safe default); any other port needs an explicit host:port, and host:* is the deliberate any-port opt-in. Non-matching targets get a 403 and an egress_denied audit record.
  • network: {} (present but empty) — deny-all: every egress attempt is refused and audited.
  • The launch env gets HTTP_PROXY/HTTPS_PROXY (and lowercase) pointing at the proxy with a per-dispatch credential in the URL; NO_PROXY=127.0.0.1,localhost,::1 keeps conductor's own local surfaces (the skill socket, a local opencode server) reachable.
  • The proxy requires that credential (Proxy-Authorization): it's a host-wide loopback listener, so without auth any local process could ride an allowlisted profile's egress. Unauthenticated clients get 407 before any target matching; the credential is stripped before anything leaves the box.

Deny by default for agent-authored work: a dispatch that came from an agent-authored plan (§11) is routed through the deny-all proxy even with no isolation: configured at all. An explicit network.egress: on the profile opts specific targets back in; trust doesn't change this — only config does.

Enforced vs. advisory

The allowlist has two strengths, and the strong one is what you should reach for:

  • Enforced — deny: true + egress: under namespace/container. The sandbox's network is removed structurally (unshare --net / --network=none); the launch is re-entered through conductor sandbox-net, an in-sandbox forwarder that pipes into conductor's filtering proxy over a unix socket (a filesystem object — it crosses the namespace boundary; nothing else does). The runtime's HTTP(S)_PROXY points at the forwarder's in-sandbox loopback address, reachable from inside and nowhere else. A runtime that ignores proxy env reaches nothing: there is no interface, no route, and no DNS — the proxy resolves CONNECT targets itself outside the sandbox, so DNS-tunnel exfiltration is closed with the rest. The allowlist is an OS boundary. (Container mode bind-mounts conductor's own static binary and the socket into the container; same-architecture image required.)
  • Advisory — egress: under mode: user (or without deny). Only the proxy env steers traffic; a runtime that ignores HTTP(S)_PROXY can still reach the network directly. conductor validate says so out loud. Use it for audit/visibility, not as a boundary — and prefer the enforced form whenever the profile runs on this box.

Plain deny: true (no list) remains the full structural cutoff. deny: true in any form is rejected for opencode runtimes (it would sever conductor's own HTTP control channel — use network: {} instead), and an egress: list needs a local launch (the proxy lives on this box; use plain deny: true with namespace mode on a remote host).

Hosts

hosts:
  sandbox:
    host: sandbox.internal
    user: ci
    isolation: { mode: user, user: agents }

Every script that host runs — including agent-authored code forced onto it by policy.agent_authored.host — executes wrapped (sudo -n -u agents -- sh -c '…') on the remote box. Modes user and namespace only; the egress proxy lives on the daemon's box and doesn't reach remote launches (validation rejects a remote egress: list — use deny: true with namespace mode there).

A host named by policy.agent_authored.host must carry an isolation: block — a "sandbox" host that doesn't isolate is a plain remote shell wearing the name, so conductor validate rejects the combination. agent_authored: { trust: full } is the documented opt-out: the same knob that lifts the allow/approve/host gates lifts this requirement.

Degradation

Situation Behavior
mode: namespace on macOS/Windows rejected by validate (local) / launch error with a clear message
mode: namespace while daemon is root (euid 0) launch refused (not a boundary as root) unless allow_root: true
sudo / unshare / docker missing launch fails with "needs X on PATH", never silently unisolated
egress policy with no proxy wired launch fails closed
enforced egress with no unix endpoint wired launch fails closed
a default filesystem mask can't be applied launch fails, never runs unmasked
isolation on a paseo runtime rejected by validate
skill: + mode: user isolation rejected by validate (claim theft under a shared uid)
agent_authored.host without isolation: rejected by validate (trust: full opts out)

Audit

Every denied egress attempt is logged and audited as {event: egress_denied, target: host:port}, so a sandboxed agent probing the network is visible in conductor report's audit trail.

Clone this wiki locally