Skip to content

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.

/etc/wardcell/config.toml
[plugin]
path = "/usr/local/libexec/wardcell/wardcell-plugin"

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_requests rule refuses — a deny is a decision, and an approval would only be overridden when the fragment merged;
  • a host a mute_hosts rule 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.

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.

{"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"}}
idminted by wardcell. Name it in the response; it is the only thing that ties an answer to its question
hostthe destination host
portthe port the connection was gated on
protocol"http" for an inspected HTTP(S) request, "tcp" for an opaque connection
method, paththe request, for an HTTP(S) destination. Absent for an opaque connection. The path is already stripped of its query string
urla convenience the plugin can put straight in front of a person; present only with a request
zonethe zone the run is in, when it is in one

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"}}
approvedmust 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_toonly for an "always": "host" or "request". Absent means "host"
byoptional, recorded as given; wardcell does not verify it
reasonshown on the refusal and recorded

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:

scopeapplies_toreachespersisted?
"once"—just this one requestno
"always""host"every request to the host for the rest of the runyes — allow_http "host"
"always""request"this exact method and pathyes — 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.

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.