Skip to content

DSL Reference

file := fragment
fragment := "fragment" STRING "{" frag_field* "}"
frag_field := "schema_version" "=" INT
| "selects_on" "=" selector
| "offer_on" "=" offer
| "requires" "=" list
| os_tag? block
selector := "{" ( "commands" "=" list | "app_bundles" "=" list ),* "}"
offer := "{" ( "group" "=" STRING | "files" "=" list ),* "}" // group required
os_tag := "[" ("linux" | "macos") "]"
block := "fs" "{" fs_field* "}"
| "net" "{" net_field* "}"
| "env" "{" env_field* "}"
| "native" "{" native_field* "}"
| "args" "{" args_field* "}"
fs_field := ( "allow_read" | "allow_write" | "deny_read" | "deny_write"
| "allow_read_metadata"
| "allow_read_ancestors"
| "allow_read_metadata_ancestors" ) list
net_field := net_list | host_block | socket_block
net_list := ( "allow_tcp" | "allow_udp"
| "deny_hosts" | "deny_tcp" | "deny_udp"
| "mute_hosts" | "expose"
| "allow_unix_sockets" | "deny_unix_sockets" ) list
host_block := "allow_http" ( STRING | list ) "{" host_field* "}"
host_field := ( "allow_requests" | "deny_requests" ) list | graphql_block
graphql_block := "graphql" STRING "{" graphql_field* "}"
graphql_field := "allow_mutations" list | "max_request_bytes" "=" INT
socket_block := "allow_unix_sockets" list "{" "mode" "=" STRING "}"
env_field := ( "passthrough" | "set" | "set_default" | "deny" ) list
native_field := "seatbelt" list
args_field := ( "append" | "unless_given" ) list
list := "[" ( STRING ),* "]"

Lists take no =. Scalars do. Every verb the DSL accepts appears above; the tables below say what each one does.

A block may name the same list field more than once — to comment one group of entries, say — and every list is in force, as if written as one.

The fragment-level fields come first; the blocks (fs, net, env, native, args) follow, each with the verbs the grammar allows inside it.

schema_version = 1

The DSL version the fragment is written for. 1 is the only version this wardcell reads: a fragment stating any other is refused before a single rule is applied, and one stating none is read as the current version. Adding an optional field never needs a bump — a new version is for a change that would otherwise be misread.

selects_on = { commands = ["git", "gitk"], app_bundles = ["com.example.app"] }

When the fragment applies automatically.

Key
commandsmatches the basename of the command being run, so git matches /usr/bin/git
app_bundlesan exact match on a launched bundle’s identifier, read from its Info.plist; macOS only

Both keys are optional and may appear together. A fragment with no selects_on applies only when something names it — a --profile, or another fragment’s requires. Refused in a workspace policy. See selects_on for the rationale.

offer_on = { group = "Toolchains", files = ["Cargo.toml", "*.xcodeproj"] } # offered under "Toolchains", pre-checked on these markers
offer_on = { group = "Tools" } # offered under "Tools", never pre-checked

Whether the fragment is a choice in wardcell policy init: the heading it sits under, and which marker files — if any — pre-check it. Always an object.

Key
grouprequired — the picker heading this fragment is shown under ("Toolchains", "Tools", "Integrations", or any name you choose)
filesoptional — basename patterns that pre-check the fragment when a checkout holds one. * is the only wildcard ("*.xcodeproj"); a pattern matches a file or directory entry. Omit it for a tool with no repository footprint

Metadata the setup picker reads, never consulted when a command runs, and kept strictly apart from selects_on. offer_on = true/false and an object with no group are refused; omit offer_on to keep a fragment out of the picker. See offer_on for how the picker uses it.

requires = ["integrations-core/git", "toolchains/apple-toolchain-core"]

Other fragments to pull in, each by its policy-root-relative path without the .sbx extension. Expanded transitively — what a required fragment itself requires comes in too — so a fragment names only its direct dependencies. A cycle is a hard error (RequiresCycle), never silently resolved, and a name no fragment under the policy root answers to is an error.

Field
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
allow_read_ancestorsfull read on each parent component, non-recursive
allow_read_metadata_ancestorsstat on each parent component

Paths expand the variables listed below. Globs: * within a component, ** across components.

A trailing / on an allow_write path states that it names a directory, so wardcell creates it before the run when it is not there — see Filesystem rules. It is not part of the path, and nothing else reads it.

Field
allow_http "<target>" { }inspected HTTP/HTTPS grant: one target, or a [ ... ] list of targets that share the block’s rules
allow_tcpopaque TCP; port required
allow_udpopaque UDP; port required
deny_hostsblocks a destination for every protocol
deny_tcp, deny_udpprotocol-specific denies
mute_hostskeeps a host’s denials out of logs, drafts and approvals, without allowing or denying it; yields to an allow_http/deny_hosts naming the same host
exposeinbound host-loopback ports
allow_unix_sockets, deny_unix_socketsUnix domain socket paths; allow_unix_sockets [...] { mode = "bind" } also permits binding (see Unix sockets)

Every net field but expose and the unix-socket ones names a destination:

target := hostpat | ipv4 | ipv4/cidr | "[" ipv6 "]" | "*"
hostpat := label ( "." label )*
label := literal | glob | "**"
entry := target [ ":" ( digits | "*" ) ]

A missing port means ports 80 and 443 for a host label, and is an error under allow_tcp/allow_udp.

In a host pattern a glob is a label carrying *, each * matching any run of characters within that one label (registry-*.npmjs.org); a whole-label * fixes the depth (*.*.aws.com is exactly two subdomain labels); and ** spans one or more labels, so any-depth reach is **.example.com. The rightmost label must be literal — a wildcard there would open a whole TLD — so “any host at all” is only ever the bare *. See Hosts and Ports for worked examples.

An inspected grant: the connection is terminated and MITM’d so each request can be matched. Its fields are request-level and exist nowhere else — a method and a path mean nothing until it is known whose path they name.

Field
allow_requestsMETHOD /path/glob rules that permit a request
deny_requestsrules that refuse one
graphql "<path>" { }body-level rules for one endpoint

An allow_http block with no rule at all is refused: an empty block grants nothing, and absence must never widen a policy.

What allow_requests and deny_requests entries look like:

rule := method WS path
method := UPPERCASE-TOKEN | "*"
path := "/" segment-glob*

Body-level rules for one endpoint path, inside an allow_http block.

Field
allow_mutationsmutation field names, matched exactly
max_request_bytesinteger; default 65536, ceiling 4194304

Inbound host-loopback ports — the one net field that opens a listener rather than reaching a destination.

entry := port | "127.0.0.1:" port | "[::1]:" port | "*"

Loopback only.

allow_unix_sockets names Unix domain socket paths (a literal, or a */** glob) the sandbox may reach. A plain list grants connect only — the sandbox may talk to a socket another process already set up:

net {
allow_unix_sockets ["/var/run/docker.sock"] # dial the docker daemon
}

Adding { mode = "bind" } also grants bind — the sandbox may create and listen on the socket, which a program serving on its own per-run socket needs. A bound socket is connectable too, so the one grant covers both:

net {
allow_unix_sockets ["/tmp/app/*.sock"] { mode = "bind" } # its own socket
}

mode is "bind", or "connect" for the connect-only grant a plain list already gives; any other value is refused.

Bind is opt-in because it is the stronger right: a plain allow_unix_sockets list never grants it, and a granted bind is scoped to the exact path or glob. The one verb works on every backend with no hand-written native clause. See the network overview for why binding where a trusted service is expected is the risk this guards against.

Field
passthroughcopy from the host environment if set
set"KEY=VALUE"
set_default"KEY=VALUE", only if not already set
denyremove, whatever set it

Precedence: passthrough → set → runtime → set_default → deny. Patterns may contain *.

Values expand the same tokens paths do, plus the proxy and plugin variables in Variables below — chiefly the proxy CA certificate, in the several forms different clients read. Which CA variable a given client reads is a property of that client, so wardcell sets none itself; a fragment points the variable its tool reads (CARGO_HTTP_CAINFO, NODE_EXTRA_CA_CERTS, …) at $WARDCELL_PROXY_CA_CERT or the matching bundle/truststore/SPKI form. See Environment Variables for the reasoning and worked examples.

Field
seatbeltraw SBPL, with tokens expanded, otherwise emitted verbatim and not validated
Field
appendswitches added in front of the user’s own arguments, never replacing or reordering them
unless_givenother spellings of the same option; the block adds nothing when the user gave any of them

Added only, never element 0, a switch the user already gave is skipped, every addition is reported on stderr, and a workspace policy may not use either field. In front of the user’s arguments rather than after, because a fragment is selected on the program and the switch belongs to the program — placed at the end it would be parsed by whichever subcommand the user typed. See Program Arguments.

$NAME tokens in an fs path or an env value are substituted by the backend at render time, so the IR keeps exactly what you wrote. The two forms differ in how a token is recognised: in an fs path a leading ~ expands to the home directory and a token is replaced wherever its text appears; in an env value ~ is literal and only a complete $NAME or ${NAME} is substituted, so $HOMELESS is left alone. A name with no value in the current run — a proxy variable when the policy grants no network, $APP_BUNDLE when the command is not an application bundle — is left exactly as written, not blanked.

VariableExpands toWhen it has a value
~, $HOMEthe user’s home directoryalways
$WORKDIRthe directory wardcell run was invoked fromalways
$TMPDIRthe per-run temporary directoryalways
$WARDCELL_BINARY_PATHthe wardcell binary itselfalways
$WARDCELL_ZONEthis run’s zone, or zoneless where no zones are definedalways
$WARDCELL_WORKDIR_IDtwelve hex characters standing for $WORKDIR — a per-workspace name short enough to hold a unix socketalways
$GIT_COMMON_DIRwhere the repository keeps its objects and refs: $WORKDIR/.git in an ordinary checkout, elsewhere in a linked worktreethe run starts inside a git repository
$APP_BUNDLEthe launched application bundle’s pathmacOS, launching an app bundle
$DARWIN_USER_CACHE_DIRthis user’s own cache bucket beside $TMPDIR, where Metal shader caches and similar per-user state livemacOS

Set only when the run has an inspecting proxy — that is, when the policy grants any network — and meant mainly for env values that point a tool at it. Which CA variable a given client reads is a property of that client, which is why wardcell sets none itself; see Environment Variables.

VariableExpands to
$WARDCELL_PROXY_URLthe proxy’s HTTP URL
$WARDCELL_PROXY_CA_CERTthe proxy CA certificate, at the path this run’s sandboxed process can open
$WARDCELL_PROXY_CA_BUNDLEthe same CA in the form a replacing CA mechanism reads
$WARDCELL_PROXY_CA_TRUSTSTOREthe same CA as a Java truststore
$WARDCELL_PROXY_CA_SPKIbase64(SHA-256(SubjectPublicKeyInfo)) — not a file, for a client that pins a key on its command line (Chromium, via args)
$WARDCELL_PLUGIN_DIRthe run’s shared plugin directory, read-only to the sandbox, where a plugin materializes files a fragment then points a tool at

All but $WARDCELL_PROXY_CA_SPKI substitute into an fs path as well as an env value; SPKI is a value, not a path, so it expands only in an env value. These are policy-path/value tokens, distinct from the process environment variables (WARDCELL_POLICY_ROOT, WARDCELL_SANDBOX, …) that wardcell reads from the shell.

Comments# to end of line. Same-line comments become rule descriptions.
Stringsdouble-quoted. \" and \\ escape; other backslashes are literal, so regex-like values work.
Integersbare digits
Booleanstrue, false

Every error names a fragment and a line. The common ones:

unknown block kind / unknown fielda typo, caught rather than silently ignored
invalid net entrye.g. a missing port under allow_tcp
allow_http block with no rulesempty blocks grant nothing and are refused
BaseOverridea fragment tried to loosen a base-layer deny
SameLayerTietwo same-layer fragments disagree at equal specificity
WorkspaceOverridesBasea .wardcell.sbx reopened a base.sbx deny, or used native or args
WorkspacePolicyInRoota .wardcell.sbx sits in the policy root; it belongs in the directory it is for
WorkspacePolicyIsALinka .wardcell.sbx is a symlink; it has to be the file itself
WorkspacePolicyHasOtherNamesa .wardcell.sbx is hard-linked; a sandbox kept out by path reaches the file by its other name
WorkspaceSelectsOn / WorkspaceOfferOna .wardcell.sbx carries selects_on or offer_on; a workspace policy already applies to its own directory, and is the result of such a choice rather than a source of it
NetGrantConflictone destination granted both inspected and opaque