-
Notifications
You must be signed in to change notification settings - Fork 0
Isolation
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.)
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.
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.
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)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 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 anyfs:paths you declare — andpivot_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 withfs:. -
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/nullover files);privileged: trueopts 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, usemode: containerormode: 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).
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, sostatis 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 advisoryHTTP(S)_PROXYstill 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 usemode: containerfor an enforced allowlist, or plaindeny: true/ an advisoryegress:withoutdeny.
Seatbelt is a kernel sandbox, not a uid trick, so the "not a boundary as root" caveat does not apply on macOS.
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.
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 explicithost:port, andhost:*is the deliberate any-port opt-in. Non-matching targets get a 403 and anegress_deniedaudit 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,::1keeps 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.
The allowlist has two strengths, and the strong one is what you should reach for:
-
Enforced —
deny: true+egress:undernamespace/container. The sandbox's network is removed structurally (unshare --net/--network=none); the launch is re-entered throughconductor 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'sHTTP(S)_PROXYpoints 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:undermode: user(or withoutdeny). Only the proxy env steers traffic; a runtime that ignoresHTTP(S)_PROXYcan still reach the network directly.conductor validatesays 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:
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.
| 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) |
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.
Setup
The model
- Connectors
- Workflows
- Reuse
- Settings-and-Templating
- Packs
- Verbs
- Code-Steps
- Stores
- Runtimes
- Model-Selection
- Model-Discovery
- Steps
- Decide-Steps
- Grouping
- Memory
- Binary-Data
- Agent-Skill
- Policy
- Gates
- Teams
- Outcomes
- Cost-Accounting
- Secrets
- Hosts
- Isolation
- Trust-and-Isolation
Connectors
Operations
- One-Shot
- Callable-Service
- Runs
- Hand-offs
- Notifications
- Migration
- Controllers (legacy name → Runtimes)