Skip to content

Filesystem Access

fs {
allow_read ["$WORKDIR", "~/.config/mytool"]
allow_write ["$WORKDIR/build"]
deny_write ["$WORKDIR/build/locked"]
}
allow_readread the path and everything under it
allow_writewrite the path and everything under it
deny_readnever readable
deny_writenever writable
allow_read_metadatastat only — existence, size, mode. Not contents.
allow_read_ancestorsfull read on each parent component, non-recursive
allow_read_metadata_ancestorsstat 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.

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.

The more path components, the more specific — declaration order is irrelevant.

allow_read ["$WORKDIR"] # 1 component below the root
deny_read ["$WORKDIR/.env"] # 2 — wins for that file
allow_read ["$WORKDIR/.env.example"] # 2, and a different path

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

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.

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:

Terminal window
wardcell policy explain -- <command> | grep <the path>