Skip to content

Listing and Entering Zones

Terminal window
# run from INSIDE a sandboxed process
wardcell zone current
wardcell zone list
wardcell zone enter user -- git push

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:

WhereCommands
On the hostrun, policy, zone list
Inside a sandbox in a zone — started by wardcell run or by zone enterzone list, zone current, zone enter
Inside a run without zonesnone

Hidden commands are refused with the reason, before they do anything:

$ wardcell run -- make
error: `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.

$ wardcell zone current
agent

The name alone, so $(wardcell zone current) needs no parsing. zone list also starts with it, and --output json carries it as "current".

Inside a sandbox, wardcell zone list asks the supervisor which zones this run may enter, and shows each one’s description:

$ wardcell zone list
current zone: agent
ZONE DESCRIPTION
push Can push to the company's git remote

The 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 list
ZONE DEFAULT PROFILE ENTER FROM DESCRIPTION
agent 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"}]}
  1. zone enter connects to the Unix socket named by $WARDCELL_ZONE_SOCKET. The socket is created per run, mode 0600, reachable only from inside this run’s own sandbox, and removed when the top-level wardcell run exits.
  2. 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.
  3. The supervisor — a thread of the trusted wardcell process that started the run, living outside the sandbox — first confirms the connecting process belongs to the sandbox it serves, then checks the target zone’s enter_from list against every zone on the way here (see below). Anything not explicitly listed is denied.
  4. If the target zone sets requires_approval, it asks the machine’s approval plugin and waits — for as long as the zone’s approval_timeout allows, or indefinitely if it sets none. A refusal, a broken plugin, or no plugin configured at all ends the entry here. See Entry Approval.
  5. 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.
  6. 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.

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.toml
[zones.agent]
profile = "developer-readonly"
default = true
[zones.build]
profile = "build"
enter_from = ["agent"]
[zones.release]
profile = "release"
enter_from = ["build"]
ChainMay enter release?
build (started with wardcell run --zone build)yes
agent → buildno — 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.

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.

$ wardcell zone enter release -- ./publish.sh
error: this zone may not enter zone 'release'; `wardcell zone list` shows the zones it may

Exit 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.sh
error: entering zone 'release' was not approved: not during the release freeze

On 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>`

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:

Terminal window
wardcell logs show <id> --category audit

The 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.

An agent that edits and tests unprivileged, and pushes with a credential:

zones.toml
[zones.agent]
profile = "developer-readonly"
default = true
[zones.push]
profile = "git-push"
description = "Can push to the company's git remote"
authenticated = true
enter_from = ["agent"]
Terminal window
wardcell run -- claude # starts in "agent" — no credentials anywhere

Inside, 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.