Filesystem Access
fs { allow_read ["$WORKDIR", "~/.config/mytool"] allow_write ["$WORKDIR/build"] deny_write ["$WORKDIR/build/locked"]}allow_read | read the path and everything under it |
allow_write | write the path and everything under it |
deny_read | never readable |
deny_write | never writable |
allow_read_metadata | stat only — existence, size, mode. Not contents. |
allow_read_ancestors | full read on each parent component, non-recursive |
allow_read_metadata_ancestors | stat on each parent component |
Allow and deny compete by specificity (see below). The three metadata and ancestor verbs are additive traversal grants and never conflict with the others.
The three metadata and ancestor verbs take effect on macOS only. On Linux a
path is either mounted or absent, so there is no way to allow stat without
allowing a read, and these verbs mount nothing. A Linux sandbox gets traversal
for free instead: every directory on the way to a mounted path exists.
Why the ancestor verbs exist
Section titled “Why the ancestor verbs exist”To open ~/.ssh/config, a process has to traverse ~/.ssh — but base.sbx
denies that directory outright, and granting allow_read ["~/.ssh"] would open
the whole thing including your private keys.
The ancestor verbs open the path to a file without opening its siblings:
[linux]fs { allow_read ["~/.ssh/config", "~/.ssh/known_hosts"] allow_read_ancestors ["~/.ssh"] # traverse the directory, not read its contents}
[macos]fs { allow_read ["~/.ssh/config", "~/.ssh/known_hosts"] allow_read_metadata_ancestors ["~/.ssh"]}The two platforms need different strengths here, which is why the real git
fragment tags them.
Specificity
Section titled “Specificity”The more path components, the more specific — declaration order is irrelevant.
allow_read ["$WORKDIR"] # 1 component below the rootdeny_read ["$WORKDIR/.env"] # 2 — wins for that fileallow_read ["$WORKDIR/.env.example"] # 2, and a different pathA tie between an allow and a deny in the same layer is an error, not a coin toss.
Globs work as in fs paths generally: * within a component, ** across
them. Variables (~, $HOME, $WORKDIR, $TMPDIR) expand at render time.
Rules resolve against the real path, so a symlinked ~/.config/foo is
matched at its target.
A trailing slash says “directory”
Section titled “A trailing slash says “directory””A write grant on a path that does not exist yet is created before the run
starts, because on Linux a grant is a bind mount and a mount needs something
to bind: without this, a tool told it may write ~/go on a machine where go
has never run cannot create it, and fails with read-only file system.
Installing a tool does not create these — ~/go appears the first time a go
command wants a module cache — so this is every first run, not an unusual
state.
To create it, wardcell has to know whether the path is a file or a directory:
fs { allow_write ["~/go/", # a directory: created as one "~/.config/tool/settings.json", # an extension: created as a file "~/.npmrc"] # neither says — nothing is created}A trailing / states it. Otherwise a name with an extension is taken to be a
file and a name without one a directory. Where neither applies — ~/.npmrc
and ~/.cargo are the same shape and one is a file, the other a directory —
nothing is created and the run warns, naming the path. Guessing there
would be worse than the failure it replaces: a directory created where a file
belongs stays on your machine and breaks the tool outside wardcell too.
The slash is not part of the path. ~/go/ and ~/go are the same rule for
every other purpose, including conflict detection and specificity, and only a
grant that is missing from the host is ever created. A path that already
exists is never touched.
A worked example
Section titled “A worked example”Open the project, not the home directory:
fs { allow_read ["$WORKDIR"] allow_write ["$WORKDIR"]
# ... but not the secrets in it deny_read ["$WORKDIR/.env", "$WORKDIR/.env.*"] deny_write ["$WORKDIR/.git/config"]}~/.aws and the rest of your home directory stay unreachable, because nothing
granted them.
How base denies
Section titled “How base denies”Two different mechanisms, and the distinction matters when you write a rule:
By omission. Most of your filesystem is unreachable simply because no rule
grants it. ~/.aws, ~/.config/gcloud and ~/.azure are in this category —
base.sbx deliberately does not name them, so an opt-in fragment
(one written for a tool that reads them) can grant them without tripping the
base-override check.
By explicit deny. A short, non-negotiable list that no layer can loosen:
~/.ssh, shell history and rc files, and the wardcell binary itself.
So “add an allow” works for the first category and is rejected for the second. Check which you are dealing with:
wardcell policy explain -- <command> | grep <the path>