Skip to content

Workspace Policies

.wardcell.sbx is layer 3 — the policy of the directory a command runs in. It lives in that directory, and a directory is a workspace because the file is there:

~/projects/payments-api/.wardcell.sbx
fragment "workspace" {
fs { allow_read ["~/.m2"] } # this project's Maven cache
net { allow_http "repo.maven.apache.org" { allow_requests ["GET /**"] } }
}

It is optional. No file means no layer 3.

Different work needs different things: a Java project reads ~/.m2, a Rust one ~/.cargo, a folder of documents neither. One file per machine could only be the union of all of them, applied to every run — which is the widest of them, every time. A file per directory is the policy of that work alone.

A workspace is any directory. Nothing looks for a git repository, so a folder of documents is a workspace on the same terms as a checkout.

The workspace is the directory you started the command in, and its .wardcell.sbx is the policy. Nothing is searched for: no walk up the tree, no looking for a git repository. The file beside you is either there or it is not.

So a run from payments-api is under payments-api/.wardcell.sbx, and a run from payments-api/src is not — that directory has its own policy or none. That is what lets each package of a monorepo carry its own, in force when a run starts there, and what stops one file left in a directory full of checkouts governing every one of them.

Neither your home directory nor / can be a workspace. A policy in either would be in force for everything underneath it, which is the machine-wide file this replaced.

Starting in the wrong place is easy to do, so a policy sitting one directory up is named rather than passed over in silence:

wardcell: /Users/you/projects/payments-api/.wardcell.sbx is not in force here; a
workspace policy applies only to the directory it is in. Start wardcell from
/Users/you/projects/payments-api to run under it.

One file applies, then, and a run under one that adds anything says so before it starts:

wardcell: workspace policy /Users/you/projects/payments-api/.wardcell.sbx, which adds:
wardcell: fs allow_read ~/.m2
wardcell: net allow_http repo.maven.apache.org

A checkout already says what it is. A Cargo.toml means Rust, a go.mod means Go, a .git means Git — so wardcell policy init reads those marker files and offers the matching toolchains and integrations in a picker:

Terminal window
cd ~/projects/payments-api
wardcell policy init
wardcell: scanned 1,204 entries in 38 ms
── Set up this workspace ──
Choose what may be used here. Saved to ./.wardcell.sbx and applied to
every wardcell run started in this directory.
Toolchains · detected
❯ [x] java pom.xml (./)
Integrations · detected
[x] git .git · via java
↑↓ move · space toggle · a show all · / filter · ? why
enter save & run · s skip once · n never here · q quit

What the scan found is pre-checked; space toggles a line, a shows every toolchain and integration on offer (not just the detected ones) — so you can add one whose marker isn’t in the tree yet, or a footprint-less tool like gh that has no marker at all — and enter writes the result:

~/projects/payments-api/.wardcell.sbx
fragment "workspace" {
schema_version = 1
requires = [
"toolchains/java", # detected: pom.xml
]
}

A fragment another one already pulls in — git, which java requires — is shown checked and “via java”, and left out of the written requires; it is there either way. The result is a requires list of bundled fragments, nothing more, so it is reviewed reach, not hand-written rules.

Detection reads the files in the checkout and nothing about the machine — it never looks at what is installed on PATH, and it never runs when a command does, only when you set the workspace up. A tool with no footprint in a repository (ssh, curl, gh, a cloud CLI) has no marker, so it is never pre-checked — a checkout can’t tell you whether you use it — but it is still a picker item under a (show all), there to add by hand. Such tools also arrive the moment they’re used, by draft or a network approval.

The first wardcell run in a directory with no .wardcell.sbx opens the same picker before the sandbox starts, so wardcell run -- claude works in a project without writing a policy by hand first. It opens only on a terminal:

SituationWhat happens
A terminal, no policy yetthe picker opens; enter writes the file and the run continues
--yesno picker — accept the detected set and write it
Not a terminal (CI, a pipe, an agent calling wardcell)no picker; one hint line naming what was detected, then the run continues with no workspace policy
--no-init, or WARDCELL_NO_INIT=1never prompts, never hints
A policy already therenever prompts again

s runs once without writing a policy and asks again next time; n writes a policy that requires nothing, so the directory is never asked again.

A checkout set up months ago can gain a go.mod. A policy already in place is never reopened on its own, but the scan still runs on each start, and a new marker close to the root for a toolchain the policy doesn’t have prints one hint — never a prompt, a grant or a block:

wardcell: go.mod detected here but toolchains/go isn't in this workspace's
policy — `wardcell policy init` to add it

Run wardcell policy init to add it (the picker opens prefilled from what the file already requires), or --no-init / WARDCELL_NO_INIT=1 to silence the hint. Only a marker within two directories of the root triggers it, so a vendored or fixture copy deep in the tree doesn’t nag.

Instead of hand-writing grants, wardcell policy draft runs a command, watches what it is refused, and writes the result into this file — creating one if there is none, and adding only what is new to one already there, so your comments and hand-narrowed grants stay as they were.

Terminal window
cd ~/projects/payments-api
wardcell policy draft -- mvn -q package

An escape hatch, not a way around base.sbx

Section titled “An escape hatch, not a way around base.sbx”

By default a workspace policy may add what the profile you are running is missing — a path a tool reads, a host it calls, a variable it needs. It may also deny anything.

What it may never do is weaken base.sbx:

AllowedRejected
fsany allow outside base.sbx’s denies; any denyan allow on, or anywhere inside, a path base.sbx denies — even a more specific one
netany host, port or request rule; any deny; opening or hard-blocking a host base.sbx only mutednothing — a deny in base.sbx wins over every allow regardless
envpassthrough, set, set_default, denynothing — a deny in base.sbx still wins over every other verb
native—every entry: raw platform rules render after base.sbx and could undo any of it

The filesystem row is the one that needs a check of its own. Across layers the most specific path wins, so a shared fragment can grant ~/.ssh/known_hosts beneath base.sbx’s deny_read ["~/.ssh"] — someone reviewed that. From a workspace policy it would be a key leak, so it is refused:

error: /Users/you/projects/payments-api/.wardcell.sbx may not override base.sbx:
`fs` entry "~/.ssh/id_ed25519" in `allow_read` reopens base.sbx's deny on
"~/.ssh". base.sbx is the floor every profile and every workspace policy stands
on.

Paths are compared as this machine will enforce them, not as written: tokens expanded, .. removed and symlinks followed. $HOME/.ssh/id_ed25519, your home directory’s absolute path, and a symlink pointing into ~/.ssh are all refused the same way. A pattern is judged by the directory it starts from, so allow_read ["~/.ss*"] is refused too, while allow_read ["/opt/tools/*"] beside a deny on /opt/secret is not.

Read the policy of a directory you did not write

Section titled “Read the policy of a directory you did not write”

A workspace policy travels with its directory. Clone a repository, copy a folder from a colleague, unpack an archive — whatever .wardcell.sbx is inside applies to the first run you start there, as written.

base.sbx is what keeps that safe to do at all: no workspace policy can reopen anything it denies, so your keys, your credential directories and everything else the floor protects stay out of reach whatever the file says.

What a workspace policy can still do is grant what base.sbx never denied. Read it before the first run, the way you would read a Makefile you are about to run:

Terminal window
cat .wardcell.sbx

Look for grants that have nothing to do with the work — your documents, a directory belonging to another project, an outbound host you do not recognise. A policy for a Java project needs ~/.m2; it has no reason to read ~/Documents or to call an address you have never heard of.

The file sits in the directory the sandboxed command works in, which is usually writable — it has to be. The policy file is the exception, so an agent cannot widen its own next run: the policy you reviewed is the policy the next run gets. Each platform enforces this its own way — macOS denies writing a .wardcell.sbx by name, Linux mounts it read-only — detailed on the macOS and Linux pages.

Two refusals follow from it. A policy that is a symlink is refused, not followed: the protected file has to be the real file, because a sandbox could otherwise swing a link (or a link halfway along a chain) to rules nobody read. A policy with more than one name (a hard link) is refused for the same reason — a sandbox kept out of one name would write through the other. Copy the file over itself and delete the other names (this also comes up after restoring a directory with a tool that shares unchanged files between snapshots).

One platform gap is worth knowing: Linux enforces the deny by mounting over the path, and cannot mount over a name in a directory that does not exist yet, so a sandboxed process on Linux can write sub/.wardcell.sbx. That changes nothing about the current run or any run you start here — it would apply only to a run you start in that subdirectory, worth remembering before starting one inside a directory an agent made.

An organisation that relies on profiles — not just base.sbx — to keep credentials or networks out of reach can turn layer 3 off entirely in /etc/wardcell/config.toml:

/etc/wardcell/config.toml
[workspace_policy]
mode = "off"

No .wardcell.sbx is read on that machine, wherever a command is run from.

Unblocking a tool while its profile is being fixed:

~/projects/payments-api/.wardcell.sbx
fragment "workspace" {
fs { allow_read ["/opt/homebrew/etc/mytool"] }
net { allow_tcp ["build-cache.internal.example:9092"] }
env { passthrough ["MYTOOL_HOME"] }
}

Then fix the profile, and delete the lines here. A grant a whole team needs belongs in a fragment in the policy root, where it is reviewed.

Narrowing one project further than the profile it runs under:

~/projects/notes/.wardcell.sbx
fragment "workspace" {
net { deny_hosts ["*.internal.example:*"] } # this work never calls internal APIs
fs { deny_read ["~/.aws", "~/.kube"] } # nor reads cloud credentials
}

Check either with wardcell policy explain, which applies the workspace policy the same way a run does and shows each of its rules with workspace as the fragment.