Listing and Entering Zones
# run from INSIDE a sandboxed processwardcell zone currentwardcell zone listwardcell zone enter user -- git pushWhat a sandboxed process sees
Section titled “What a sandboxed process sees”Every sandboxed process is started with WARDCELL_SANDBOX=1, and a process in a
zone also gets WARDCELL_ZONE (the zone it is in) and WARDCELL_ZONE_SOCKET
(its supervisor). wardcell reads them to offer only the
commands that work where it is:
| Where | Commands |
|---|---|
| On the host | run, policy, zone list |
Inside a sandbox in a zone — started by wardcell run or by zone enter | zone list, zone current, zone enter |
| Inside a run without zones | none |
Hidden commands are refused with the reason, before they do anything:
$ wardcell run -- makeerror: `wardcell run` is not available inside an wardcell sandbox: a sandbox cannot start another sandbox. To run a command with different permissions, see `wardcell zone list` and `wardcell zone enter`.These variables are a convenience, not a boundary. A process that unsets or forges them gains nothing: a sandbox still cannot start another sandbox, and whether a zone may be entered is decided by the supervisor.
Which zone am I in?
Section titled “Which zone am I in?”$ wardcell zone currentagentThe name alone, so $(wardcell zone current) needs no parsing. zone list also
starts with it, and --output json carries it as "current".
Listing zones
Section titled “Listing zones”Inside a sandbox, wardcell zone list asks the supervisor which zones this run
may enter, and shows each one’s description:
$ wardcell zone listcurrent zone: agent
ZONE DESCRIPTIONpush Can push to the company's git remoteThe supervisor answers from the same enter_from lists it uses to decide
zone enter, so a zone it lists is one it would let this run into. A zone
whose enter_from admits itself is listed too, marked (current). Zones the
run cannot enter are not shown, and neither is how any zone is built — its
profile or where its credentials come from.
On the host, wardcell zone list reads zones.toml and shows every zone:
$ wardcell zone listZONE DEFAULT PROFILE ENTER FROM DESCRIPTIONagent yes developer-readonly - -push git-push agent Can push to the company's git remote--output json prints the same as JSON, for an agent or a script:
{"current":"agent","zones":[{"name":"push","description":"Can push to the company's git remote"}]}What happens on zone enter
Section titled “What happens on zone enter”zone enterconnects to the Unix socket named by$WARDCELL_ZONE_SOCKET. The socket is created per run, mode0600, reachable only from inside this run’s own sandbox, and removed when the top-levelwardcell runexits.- It sends the target zone and the command. It does not say which zone it is calling from: the supervisor only ever answers for the zone it was started for.
- The supervisor — a thread of the trusted
wardcellprocess that started the run, living outside the sandbox — first confirms the connecting process belongs to the sandbox it serves, then checks the target zone’senter_fromlist against every zone on the way here (see below). Anything not explicitly listed is denied. - If the target zone sets
requires_approval, it asks the machine’s approval plugin and waits — for as long as the zone’sapproval_timeoutallows, or indefinitely if it sets none. A refusal, a broken plugin, or no plugin configured at all ends the entry here. See Entry Approval. - On approval it resolves the target zone’s profile and spawns the command as a brand-new sandboxed child under that policy, with its own proxy listener, its own identity, and its own supervisor.
- It blocks until that child exits and relays the exit status back.
The child is given the standard streams of the wardcell zone enter process
that asked for it, not the run’s own — zone enter passes its stdin, stdout
and stderr to the supervisor over the socket, and the supervisor hands them to
the command it starts. So the output goes to whoever ran zone enter: a person
sees it in their terminal, and an agent that captured the zone enter
subprocess sees it in that capture — which is how a coding agent in a
low-privilege zone reads why a git push it escalated for failed. Only the
exit status travels back over the socket; there is no separate output to relay.
The supervisor identifies the connecting process by the kernel’s record of who connected: on macOS by the Seatbelt identity every process of the run carries, on Linux by the run’s network namespace. This does not rely on the sandbox keeping other processes away from the socket — a process on the host outside any sandbox is refused too.
One sandbox runs one zone enter at a time; zone list is still answered while
it runs. A supervisor serves at most 4 connections at once, and a connection
that has not sent its request within 5 seconds is closed.
Entering from an entered zone
Section titled “Entering from an entered zone”The command zone enter starts can list and enter zones too. What it may enter
is checked against every zone on its chain — the zone its run started in,
and each zone entered since — not only the zone it is in now:
[zones.agent]profile = "developer-readonly"default = true
[zones.build]profile = "build"enter_from = ["agent"]
[zones.release]profile = "release"enter_from = ["build"]| Chain | May enter release? |
|---|---|
build (started with wardcell run --zone build) | yes |
agent → build | no — agent could not enter release itself |
zone enter runs any command the caller chooses, so an agent entering build
can run another agent there. If only the last zone counted, that agent could
enter whatever build may, and so on: every zone reachable through any chain
of enter_from lines would be reachable from agent. Checking the whole
chain means entering zones one after another never reaches further than the
first zone could directly, and each enter_from line means exactly what it
says. To let agent reach release through build, list both:
enter_from = ["agent", "build"].
A chain is at most 8 zones deep. Zones that may enter each other — or a
zone with enter_from = ["*"] — would otherwise nest sandboxes and proxies
without end.
The calling process is never widened
Section titled “The calling process is never widened”Step 5 is the important one. The supervisor does not mutate, reparent, or hand privileges to the sandbox that asked. It constructs a new one.
A sandboxed process cannot repoint its own egress or ask its own proxy to mint a different identity — that proxy is scoped to the zone it was started in and has no mechanism to become another. There is no self-elevation path to find a bug in.
Denials
Section titled “Denials”$ wardcell zone enter release -- ./publish.sherror: this zone may not enter zone 'release'; `wardcell zone list` shows the zones it mayExit code 1. Fix it by adding the calling zone to the target’s enter_from,
if that is genuinely what you want.
A zone that requires approval has a second way to say no, which reads differently because it means something different — asking again may work:
$ wardcell zone enter release -- ./publish.sherror: entering zone 'release' was not approved: not during the release freezeOn the host:
error: `wardcell zone enter` only works inside a sandbox that `wardcell run` started in a zone; to run a command in a zone from here, use `wardcell run --zone <ZONE> -- <COMMAND>`Auditing
Section titled “Auditing”Every entry attempt becomes an audit.decision event — and an audit.completed
event when the command finishes — in the run’s
log, allowed or denied: the run, calling zone,
the chain that led to it, target zone, command, decision, and exit status — and
for a zone that requires approval, what the plugin answered, who it said
approved, and its own reference for the request. Listing zones changes nothing
and is not logged.
Those events — not the sandboxed process’s own output — are the record of privilege escalation in a run. Read them on their own:
wardcell logs show <id> --category auditThe log is one file per run, named by the run id, under
~/Library/Logs/wardcell/runs/ on macOS and $XDG_STATE_HOME/wardcell/runs/ (or
~/.local/state/wardcell/runs/) on Linux. wardcell doctor prints the directory.
The run id is the same value the trace carries
and the same one an approval plugin is asked about, so a line here can be found
in either without a translation table.
Example
Section titled “Example”An agent that edits and tests unprivileged, and pushes with a credential:
[zones.agent]profile = "developer-readonly"default = true
[zones.push]profile = "git-push"description = "Can push to the company's git remote"authenticated = trueenter_from = ["agent"]wardcell run -- claude # starts in "agent" — no credentials anywhereInside, the agent edits files and runs tests freely. When it needs to push, it
runs wardcell zone list, finds push, and runs
wardcell zone enter push -- git push. The supervisor checks enter_from, and
git push runs in a fresh sandbox where the proxy attaches the credential.
The agent never holds it.