Drafting with policy draft
Hand-writing a policy for an unfamiliar tool means guessing. policy draft
runs the tool, records what it actually reached for, and writes the result into
the directory’s workspace policy — the
.wardcell.sbx beside your working copy — so the next run has what it needs.
cd ~/projects/payments-apiwardcell policy draft -- npm installPass --print to show the fragment on the terminal and write nothing, for a
preview or to pipe elsewhere (stdout carries only the fragment; the command’s
own output goes to stderr):
wardcell policy draft --print -- npm install > npm.draft.sbxThe loop
Section titled “The loop”flowchart LR A["draft"] --> B["review"] --> C["fold into a fragment"] --> D["run"] D -->|"still denied?"| A
Re-running draft after folding the last round in is expected. That
regeneration is the loop — there is no persisted history and nothing is ever
promoted automatically.
Learn mode
Section titled “Learn mode”Every rule still evaluates, but nothing blocks. The network is fully open:
the proxy observes every connection and request — including to hosts the policy
would refuse, which it still MITMs so the method and path are recorded, not
just the host. So a draft learns the complete set of URLs a run touched and
scopes each host’s allow_requests to exactly the METHOD /path requests it
saw. A host reached over opaque TCP, or already granted allow_tcp, is spliced
rather than inspected and drafts as allow_tcp.
A host that turns out not to speak HTTP/1 — a gRPC or HTTP/2 origin the proxy
can decrypt but cannot re-send as HTTP/1, showing up as repeated invalid HTTP version errors — is drafted as allow_tcp, not allow_http: an inspected grant
would MITM it and keep failing, while opaque TCP lets the client negotiate HTTP/2
with the origin directly.
How a refusal behaves differs by platform. On Linux it is recorded and passed through, so the command completes and you see everything it wanted rather than only the first denial — the draft mounts the host’s filesystem as it is. On macOS the command runs under the same enforcing profile as a real run and denials are read from the system log, so a command that gives up at its first refusal can need several rounds to converge. Either way a draft is not a sandboxed run — on Linux it is not filesystem-confined at all, the command can read and write anything you can — so use it to discover, not to contain, and read the fragment it prints before folding it in.
What was reached but not granted
Section titled “What was reached but not granted”Not everything a draft observes becomes a grant: a host a rule denies, a muted
telemetry host, an access by bare IP. These are reported, not dropped —
every writing run prints a Not added, and why section to stderr listing each
one and the reason.
Two of them are also written into the file as commented rules you can accept
by deleting the leading #: a mute_hosts
host, and a destination reached with no hostname. Each is tagged so a later
draft reconciles exactly those lines — adding one newly reached, dropping one
you have accepted or that is no longer seen — while leaving your own comments
and grants untouched:
net { allow_http "api.example.com" { allow_requests [ "GET /v1/orders", # the requests the run actually made are proposed ] }
# allow_http "telemetry.example.com" { allow_requests ["* /**"] } # not added — muted in base; uncomment to allow [wardcell-not-added] # allow_tcp ["93.184.216.34:443"] # not added — no hostname; add the name instead, or uncomment if the address is stable [wardcell-not-added] }Only those two are written, because each is a complete rule that works the
moment its # is gone. The rest stay on the terminal — they are not a line you
could simply uncomment:
- What a rule denies stays denied: granting the denied path or anything
broader is refused at merge (a path strictly beneath a deny can still be
granted — the bundled git fragment does this for
~/.ssh/known_hosts). - A
deny_hostshost, or adeny_requestsrequest on an allowed host, each named with the fragment the rule lives in. - A gRPC/HTTP-2 origin already granted
allow_httpelsewhere: it needsallow_tcp, but the resolver refuses a destination named by both, so the swap is a hand edit. - A local address a program bound, and the filesystem notes (renames that cannot cross a mount, grants that conflict with a directory the draft takes).
How each platform observes
Section titled “How each platform observes”macOS scrapes Seatbelt denials from the system log, so it sees what was denied. Linux traces syscalls directly, so it sees every attempt — and therefore also reports paths the policy would have allowed, useful when you are tightening a grant rather than opening one. Both produce the same fragment.
A path only looked at (stat, access, readlink) drafts as
allow_read_metadata on macOS, but as allow_read on Linux — bwrap can only
mount a path or leave it out, so a metadata-only rule would mount nothing. The
full per-platform grant shaping (directory grants for creates and renames,
why nothing is granted inside another grant) is in the
Linux reference.
Reading the output
Section titled “Reading the output”A writing draft prints a short report on stderr — the path written and the entries added — and nothing on stdout. The denials print as a table:
OPERATION PATH WHY SEENnetwork-outbound api.example.com:443 GET /v1/ordersfile-read-data ~/.npmrc ×12PATH is the machine-readable half — a filesystem path, or a destination plus
the request that wanted it. A path under your home, the working directory or the
scratch directory is shown by its token (~, $WORKDIR, $TMPDIR), the same
portable spelling the drafted grant uses. The table collapses repeats: SEEN
shows how often a row recurred (blank for one), which tells you whether it is
central or a single probe. Muted hosts are left out of the table — they
appear in Not added, and why instead, so a quieted host does not add noise.
The generated fragment
Section titled “The generated fragment”# Draft fragment generated by `wardcell policy draft` — review before# folding these entries into a real fragment.fragment "npm-draft" { schema_version = 1
fs { allow_read [ "~/.npmrc", # file-read-data ] }
net { allow_http "registry.npmjs.org" { allow_requests [ "GET /express", "GET /express/-/express-4.18.2.tgz", ] # These are the exact requests observed. Replace a path with a # glob (`/v1/**`) where the tool will vary it between runs. } }}Three things it does deliberately:
- A path under a known root is written by its token, not its absolute form —
~/.npmrc,$WORKDIR/…,$TMPDIR/…. A fragment holding/Users/alice/…only works for Alice; the token expands per-user at enforcement, so a teammate who clones the repo can use it. - A host with no observed request gets the widest grant, written out as
allow_requests ["* /**"]with a comment to narrow it. The connection was refused before any request, so there is nothing to scope it with — and that is the grant you most need to see before accepting it. - A destination with no hostname is written commented, to accept or replace.
Addresses move and an IP entry is broader than the name you meant, so add the
real hostname or delete the
#if the address really is stable.
Options
Section titled “Options”Draft-specific flags; see the CLI reference for the full set.
--profile <PATH> | the fragment to run under and draft for; the draft holds only what it is missing |
--zone <NAME> | draft under a zone, when the machine defines zones.toml |
--output pretty|text|json | default pretty |
--print | print the fragment on stdout and write nothing |
--yes / --no-init | accept detected toolchains without the picker / never open it |
Without --profile, selection works as wardcell run would, and a first draft in
a directory with no .wardcell.sbx opens the setup
picker on a terminal.
Writing it into the workspace
Section titled “Writing it into the workspace”By default draft writes the workspace policy
of the directory you ran in, creating the .wardcell.sbx if needed. With a file
already there it adds only what is new, leaving your comments, ordering and
hand-narrowed grants as they were — so drafting again after a refusal adds that
one grant rather than replacing what you agreed to. The directory has to be one a
workspace policy can govern: not your home or the filesystem root, where the file
would apply to everything beneath it (that is refused — run from the directory
the policy is for, or use --print).
A writing draft keeps the file current while the command runs, flushing every
few seconds, so a long session (an agent, a server) keeps what it learned even if
it is killed. Each flush writes a temporary file and renames it into place, so a
crash can never truncate the policy — you see the whole old file or the whole new
one. A grant is tracked by what it grants, not the denial that produced it: if you
narrow or remove one while the command runs, a later flush does not put it back,
and a broader grant that supersedes an earlier one reconciles the narrower away.
A file the run creates is private to you (0600). This is layer 3 — the fragment
belongs to that directory alone, and a grant a whole team needs still belongs in a
reviewed fragment in the policy root.
JSON output
Section titled “JSON output”--output json prints one document on stdout, for a script or an agent driving
the loop:
{ "command": ["npm", "install"], "exit_status": 0, "denials": [ { "operation": "file-read-data", "path": "~/.npmrc", "reason": null, "count": 2 }, { "operation": "network-outbound", "path": "registry.npmjs.org:443 GET /express", "reason": null, "count": 1 } ], "draft": "# Draft fragment generated by `wardcell policy draft` ...\n", "advisories": " MUTED (mute_hosts) — reached but kept quiet; still blocked ...\n"}Each distinct denial appears once in first-seen order with its count; muted hosts
are omitted, as in the table. exit_status is null when a signal ended the
command. draft is the fragment exactly as --print renders it; advisories is
the Not added, and why text, absent when there was none. When the draft writes
(the default), written (the path) and added (the entries put into an existing
file) also appear. The exit code is always the command’s own.
Never auto-promoted
Section titled “Never auto-promoted”draft produces a new, unapplied fragment for the directory you ran in — only
that directory’s workspace .wardcell.sbx, never the reviewed fragments in the
root. Review it, narrow it, and commit it like any other change: the whole value
is that a person saw the grant before it took effect.