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:
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.
Which file is in force
Section titled “Which file is in force”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; aworkspace 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 ~/.m2wardcell: net allow_http repo.maven.apache.orgSetting one up
Section titled “Setting one up”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:
cd ~/projects/payments-apiwardcell policy initwardcell: 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 quitWhat 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:
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 run sets it up too
Section titled “The first run sets it up too”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:
| Situation | What happens |
|---|---|
| A terminal, no policy yet | the picker opens; enter writes the file and the run continues |
--yes | no 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=1 | never prompts, never hints |
| A policy already there | never 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.
When a repository grows a new toolchain
Section titled “When a repository grows a new toolchain”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 itRun 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.
Writing one from a draft
Section titled “Writing one from a draft”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.
cd ~/projects/payments-apiwardcell policy draft -- mvn -q packageAn 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:
| Allowed | Rejected | |
|---|---|---|
fs | any allow outside base.sbx’s denies; any deny | an allow on, or anywhere inside, a path base.sbx denies — even a more specific one |
net | any host, port or request rule; any deny; opening or hard-blocking a host base.sbx only muted | nothing — a deny in base.sbx wins over every allow regardless |
env | passthrough, set, set_default, deny | nothing — 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 standson.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:
cat .wardcell.sbxLook 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 sandbox cannot write its own policy
Section titled “The sandbox cannot write its own policy”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.
Turning the layer off
Section titled “Turning the layer off”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:
[workspace_policy]mode = "off"No .wardcell.sbx is read on that machine, wherever a command is run from.
Examples
Section titled “Examples”Unblocking a tool while its profile is being fixed:
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:
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.