Skip to content

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"]
}
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_tcp grant. 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). Use allow_http for 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:port alone.
GrantsInspected?
allow_http "x" { ... }HTTP/HTTPS to x, per requestyes — method, path, body
allow_tcp ["x:port"]opaque TCPno
allow_udp ["x:port"]opaque UDPno
deny_hosts, deny_tcp, deny_udphard 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_socketsUnix domain socket paths—

deny_hosts is protocol-agnostic: blocking a destination does not leave a raw TCP route to it open.

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.

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"]
}

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

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.

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.