Network Approval Plugins
approvals.network turns a network block into a question. When a run reaches a
host or a request no grant covers, the proxy — instead of refusing it —
asks the plugin whether to allow it, and waits. It is how an agent doing
open-ended work (reading arbitrary repos, following links) keeps going past the
first un-granted host, with a person deciding each new destination.
It is enabled by nothing but the handshake: a plugin that lists
approvals.network in its wardcell.init features is asked; one that does not is
not, and a block stays a block. There is no policy setting to turn it on.
[plugin]path = "/usr/local/libexec/wardcell/wardcell-plugin"Only a missing grant is ever asked about
Section titled “Only a missing grant is ever asked about”The question is asked for exactly one reason: nothing in the policy grants the destination. Two kinds of block are never asked about, because they are not missing grants:
- a host or request an explicit
deny_hosts/deny_tcp/deny_requestsrule refuses — a deny is a decision, and an approval would only be overridden when the fragment merged; - a host a
mute_hostsrule quieted — muting means “do not ask me about this,” so it stays silently blocked.
So a plugin only ever sees destinations the policy is simply missing a rule for.
That includes a request to a host the policy does grant whose allow_requests
do not cover it — GET /b on a host allowed only GET /a — which is asked
about rather than refused, so a narrow grant (including one approved and saved on
an earlier run) can be extended a request at a time.
Seeing the full URL
Section titled “Seeing the full URL”An HTTPS CONNECT names only host:port — no path. To show which page an
agent wants (not just github.com), wardcell does what a draft does: it opens the
tunnel and terminates the TLS with its own CA, reads the real METHOD /path, and
then asks. This is local — the leaf certificate is minted from the run’s CA and
nothing is sent to the real server. The server is contacted only if you allow the
request; on a refusal nothing is dialled and, for a name, nothing is resolved, so
a secret encoded into a hostname never leaves the machine on a deny.
Plaintext HTTP already carries the path, so it is shown directly. An opaque TCP connection (or anything that is not HTTP/TLS) has no URL to show and is asked about on host and port alone.
The question
Section titled “The question”{"jsonrpc":"2.0","id":"9f2c…-3","method":"approvals.network","params":{"host":"raw.githubusercontent.com","port":443,"protocol":"http","method":"GET","path":"/org/repo/main/README.md","url":"https://raw.githubusercontent.com/org/repo/main/README.md","zone":"work"}}id | minted by wardcell. Name it in the response; it is the only thing that ties an answer to its question |
host | the destination host |
port | the port the connection was gated on |
protocol | "http" for an inspected HTTP(S) request, "tcp" for an opaque connection |
method, path | the request, for an HTTP(S) destination. Absent for an opaque connection. The path is already stripped of its query string |
url | a convenience the plugin can put straight in front of a person; present only with a request |
zone | the zone the run is in, when it is in one |
The answers
Section titled “The answers”As with zone approvals, a question and its answer
are separate messages joined by id, so a plugin can acknowledge with
wardcell.pending and answer minutes or hours later from a thread of its own.
The wait is indefinite unless the run is interrupted.
{"jsonrpc":"2.0","id":"…","result":{"approved":true,"scope":"once"}}{"jsonrpc":"2.0","id":"…","result":{"approved":true,"scope":"always","applies_to":"host","by":"alice@example.com"}}{"jsonrpc":"2.0","id":"…","result":{"approved":true,"scope":"always","applies_to":"request","by":"alice@example.com"}}{"jsonrpc":"2.0","id":"…","result":{"approved":false,"reason":"not this host"}}approved | must be the boolean true. Anything else — a missing field, a string — refuses |
scope | "once" or "always", exactly as a zone approval; absent or unrecognised means "once" |
applies_to | only for an "always": "host" or "request". Absent means "host" |
by | optional, recorded as given; wardcell does not verify it |
reason | shown on the refusal and recorded |
Once, or always — and how wide
Section titled “Once, or always — and how wide”The answer has the same two parts a zone approval has: a scope that is
"once" or "always" (the zone’s own vocabulary), and — for an "always", the
way a zone approval has covers — an applies_to that says how wide:
scope | applies_to | reaches | persisted? |
|---|---|---|---|
"once" | — | just this one request | no |
"always" | "host" | every request to the host for the rest of the run | yes — allow_http "host" |
"always" | "request" | this exact method and path | yes — allow_http "host" { allow_requests ["METHOD /path"] } |
An "always" is remembered for the run (so the same thing is not asked
again) and written into the .wardcell.sbx of the directory the run started in,
so the next run does not ask at all. "once" opens only the request it answered
and persists nothing. An "always" with applies_to: "request" on an opaque
connection — which has no method or path to scope to — is taken as "host".
The written grant holds no query string and no header; it is a host, or a method and a path. A directory that cannot hold a workspace policy (a home directory, the filesystem root) keeps the grant for the run but has nowhere to save it, and says so in the log.
Fail closed
Section titled “Fail closed”A plugin that is unreachable, answers unreadably, or has its connection drop refuses the request — the same posture as every other gate. An approval that opened whenever the thing guarding it broke would be worse than none.