Network Access
Nothing is reachable by default — not even DNS. A net block adds
destinations.
net { allow_http "api.example.com" { allow_requests ["GET /v1/**", "POST /v1/orders"] }
allow_tcp ["db.internal:5432"] deny_hosts ["ads.example.com"] mute_hosts ["telemetry.example.com"] expose ["3000"]}Two gates
Section titled “Two gates”flowchart LR
A["sandboxed process"] --> B{"connection gate<br/>host:port"}
B -->|refused| X["✘"]
B -->|allowed| C{"request gate<br/>method + path"}
C -->|refused| X
C -->|allowed| D{"body gate<br/>GraphQL"}
D -->|refused| X
D -->|allowed| E["origin"]
The connection gate decides where the sandbox may connect. The request gate decides what it may ask for once it gets there — and only exists inside a tunnel the proxy inspects.
Almost every HTTP(S) flow is opened and decided at the request gate — granted
or not, with or without an approval plugin — so a block names the exact method
and path that was attempted, and the log shows it. The connection gate refuses
before a tunnel opens only a non-HTTP stream (SSH, a database, a raw
protocol) that no rule grants: there is no request line in such a stream to
show, so the destination’s host:port is the whole record.
Three cases do not reach the request gate, and so are recorded as host:port
without a method or path:
- An
allow_tcpgrant. It promises an opaque, byte-for-byte relay — so a pinning client meets the server’s own certificate, not wardcell’s — and is never inspected, even when the traffic happens to be HTTP(S). Useallow_httpfor anything you want gated per request. - A conventional non-HTTP port, un-granted. A destination no rule grants on
a port wardcell treats as non-HTTP by convention (SSH’s 22, a database’s 5432,
and so on) is refused at the connection gate without opening a tunnel, so it
fails fast rather than waiting out a handshake that will not come. An HTTP
service that runs on such a port is refused the same way — on
host:port, not per request — so do not run one you want inspected on a port in that set. - A TLS setup that never completes. If a client abandons the handshake or
rejects wardcell’s leaf (certificate pinning), no request is ever decrypted, so
there is nothing to gate. The attempt to an un-granted host is still recorded,
on
host:portalone.
The verbs
Section titled “The verbs”| Grants | Inspected? | |
|---|---|---|
allow_http "x" { ... } | HTTP/HTTPS to x, per request | yes — method, path, body |
allow_tcp ["x:port"] | opaque TCP | no |
allow_udp ["x:port"] | opaque UDP | no |
deny_hosts, deny_tcp, deny_udp | hard blocks | — |
mute_hosts ["x"] | keeps a host’s denials quiet, without allowing or denying | — |
expose ["port"] | inbound to a listener inside the sandbox | — |
allow_unix_sockets, deny_unix_sockets | Unix domain socket paths | — |
deny_hosts is protocol-agnostic: blocking a destination does not leave a raw
TCP route to it open.
Unix sockets
Section titled “Unix sockets”allow_unix_sockets grants access to Unix domain socket paths. A plain list is
connect-only — the sandbox may dial a socket another process set up (the
docker daemon, the ssh-agent, the system resolver), but not create one:
net { allow_unix_sockets ["/var/run/docker.sock"]}A socket the program owns — a per-run socket it creates and then talks to
itself — needs { mode = "bind" }, which grants bind and connect together:
net { allow_unix_sockets ["/tmp/app/*.sock"] { mode = "bind" }}Bind is a separate, opt-in right because creating a socket where a trusted
service is expected would let a sandboxed process stand up an impostor in its
place — a fake ssh-agent that captures key approvals, say. So connect never
implies bind, and a bind is scoped to the exact path or glob. The one verb
covers both platforms: no backend-specific native clause is needed to bind.
Muting a host
Section titled “Muting a host”mute_hosts is for a destination you expect to be blocked and do not want
filling the logs — telemetry, a crash reporter, an auto-updater that retries
every few seconds. It is not a decision: a muted host is still blocked by
the absence of a grant, but its denials are kept out of the logs, out of
wardcell policy draft’s proposals, and out of approval prompts.
The difference from deny_hosts is overridability. A deny_hosts is a hard
block that wins everywhere and cannot be reopened by a more local fragment — so
a base policy that deny_hosts’d a telemetry endpoint would stop a repo that
genuinely needs it. A mute_hosts only quiets; a repo’s own .wardcell.sbx is
free to allow_http the host (open it) or deny_hosts it (hard-block it), and
either, being a real rule about that host, takes over from the mute.
net { # Expected, noisy, and blocked — but a repo that needs it can still open it. mute_hosts ["telemetry.example.com"]}Approving a host live
Section titled “Approving a host live”A host or request no grant covers is normally blocked. If the machine is
configured with a network-approval plugin,
wardcell instead asks — showing the full URL for an HTTP(S) request — and allows
it if you say so, so an agent doing open-ended work is not dead on the first
un-granted host. “Allow Always” writes the grant into the working directory’s
.wardcell.sbx so the next run does not ask. A deny_hosts or mute_hosts host
is never asked about: a deny is a decision, and a mute stays quiet.
allow_http or allow_tcp?
Section titled “allow_http or allow_tcp?”allow_http whenever the traffic is HTTP or HTTPS. You get per-request
rules, credential injection, and useful learn-mode output. The proxy
terminates TLS with a per-run CA to do it.
allow_tcp only when it cannot be inspected — Postgres, SSH, a custom
binary protocol. It is a materially weaker grant, and one destination cannot
have both.
gRPC is allow_http. It is HTTP, and the proxy carries HTTP/2, so a gRPC
destination gets per-request rules and credential injection like any other. It
is worth saying because reaching for allow_tcp instead is the natural guess —
and it is the one choice that cannot work in an
authenticated zone, since a tunnel is not
inspected and inspection is where an identity is injected.
HTTP/1.1 and HTTP/2
Section titled “HTTP/1.1 and HTTP/2”Both, at both ends, chosen by ALPN in each connection’s own handshake. Nothing in a policy names a version and nothing needs to: a client that speaks HTTP/2 to the sandbox’s proxy has its requests carried over HTTP/2 to an origin that offers it, and over HTTP/1.1 to one that does not.
Request rules are written the same way either way — allow_requests ["POST /**"]
matches an h2 request’s :method and :path exactly as it matches a 1.1
request line.
IPv4 and IPv6
Section titled “IPv4 and IPv6”Both, with no policy knob. A rule names a host or a CIDR, not an address family:
a name that resolves to an AAAA record is reached over IPv6 and one that
resolves to A over IPv4, each gated by the same rule, and a literal v6
address or prefix ([2001:db8::1]:443, 2001:db8::/32) is matched like any
other. Which family a client ends up on, and the one gap on macOS capture, are
platform details.
Reading the rest
Section titled “Reading the rest”- Hosts and Ports — what a label grants
- Request Rules —
allow_requests/deny_requests - GraphQL — mutation allowlists
- Raw TCP and UDP — opaque grants
- Inbound Ports —
expose