Skip to content

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.

Terminal window
cd ~/projects/payments-api
wardcell policy draft -- npm install

Pass --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):

Terminal window
wardcell policy draft --print -- npm install > npm.draft.sbx
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.

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.

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_hosts host, or a deny_requests request on an allowed host, each named with the fragment the rule lives in.
  • A gRPC/HTTP-2 origin already granted allow_http elsewhere: it needs allow_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).

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.

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 SEEN
network-outbound api.example.com:443 GET /v1/orders
file-read-data ~/.npmrc ×12

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

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

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|jsondefault pretty
--printprint the fragment on stdout and write nothing
--yes / --no-initaccept 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.

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.

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

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.