Approval Plugins
approvals.zone decides whether a particular entry into a zone happens. It is
asked after enter_from has already allowed the entry, so it can refuse
entries the policy permits and can never permit one the policy does not.
For what a gated zone looks like to the person using it — the error, the exit code, the audit line — see Entry Approval. This page is the wire protocol.
[plugin]path = "/usr/local/libexec/wardcell/wardcell-plugin"[zones.release]profile = "release"enter_from = ["work"]requires_approval = trueapproval_timeout = "4h" # optional; omit to wait as long as it takesOne plugin serves every gated zone on the machine, and every zone of a run. The
question names its target, so a plugin that treats zones differently does that
itself.
The answer comes back whenever it comes back
Section titled “The answer comes back whenever it comes back”An approval usually ends up in front of a person, and that takes as long as it
takes. So a question and its answer are separate messages, joined by id, on
the run’s one connection:
wardcell → {"jsonrpc":"2.0","id":"4f3c…-0","method":"approvals.zone","params":{…}}plugin → {"jsonrpc":"2.0","method":"wardcell.pending","params":{"id":"4f3c…-0","reference":"slack:C0AB/p1700"}} … minutes or hours …plugin → {"jsonrpc":"2.0","id":"4f3c…-0","result":{"approved":true,"by":"alice@example.com"}}Several questions may be outstanding at once, and they may be answered in any order. Nothing about answering blocks reading the next question.
A plugin that can answer immediately simply does — wardcell.pending is optional,
and a synchronous plugin is a legitimate plugin. The
examples do both: a
short allowlist answered in the same breath, and everything else acknowledged and
answered from a thread of the plugin’s own.
Lifetime
Section titled “Lifetime”- wardcell picks a socket path and execs the plugin once per run, with
WARDCELL_PLUGIN_SOCKETset to it. Nothing else is passed. - The plugin binds and listens. wardcell is the client and connects once.
- A plugin that has not bound within 10 seconds, or that does not answer the
wardcell.inithandshake, or that does not implementapprovals.zonewhen a zone requires it, fails the run’s startup — rather than failing the first gated entry for a reason unrelated to it. - The plugin serves until the run ends, and is killed with it.
The question
Section titled “The question”{"jsonrpc":"2.0","id":"9f2c4a…-0","method":"approvals.zone","params":{"run":"9f2c4a…","chain":["work"],"target":"release","command":["npm","publish"],"cwd":"/Users/me/app"}}id | minted by wardcell. The only thing that ties an answer to a question — name it in the response |
run | the run this entry belongs to. Also the trace id in its telemetry |
chain | how the asking process got here: the zone its run started in, then each zone entered since. A one-element chain has not escalated yet |
target | the zone it wants to enter |
command | what it wants to run there, as argv |
cwd | where the run was started, or null if that could not be read. Often the most telling part: npm publish in one checkout is not the same request as the same words in another |
wardcell mints the identifier rather than taking one from the plugin, so it is known before any reply arrives and a plugin that reused one could not deliver one entry’s answer to another.
The answers
Section titled “The answers”Every message names the id it concerns. A message that names none cannot be
matched to a question and is reported and skipped — wardcell does not guess “the
only one outstanding”, because that delivers one entry’s approval to another as
soon as two are in flight.
{"jsonrpc":"2.0","method":"wardcell.pending","params":{"id":"…","reference":"slack:C0ABCDEF/p1700"}}{"jsonrpc":"2.0","id":"…","result":{"approved":true,"by":"alice@example.com","scope":"once"}}{"jsonrpc":"2.0","id":"…","result":{"approved":true,"by":"alice@example.com","scope":"always"}}{"jsonrpc":"2.0","id":"…","result":{"approved":true,"by":"alice@example.com","scope":"always","covers":["npm","install"]}}{"jsonrpc":"2.0","id":"…","result":{"approved":false,"reason":"not during the release freeze"}}{"jsonrpc":"2.0","id":"…","error":{"code":-32000,"message":"the approvals service is unreachable"}}wardcell.pending | optional, and a notification — it has no id of its own and nothing is sent back. Says the question has been seen and decides nothing. Its reference is your name for the request — a message timestamp, a ticket key — and it is recorded on the audit line, so an auditor reading “refused” can go and read the thread where it was refused |
approved | must be the boolean true. A string "true", a missing field, or anything else refuses — the one field that decides this is never defaulted |
by | optional, recorded as given. It is your claim about who answered; wardcell does not verify it |
scope | "once" (the default) or "always" — see below. Only read on an approval |
covers | optional, and only with "always": the words a later command line must start with for this approval to reach it. Absent means the command you were asked about, exactly — see below |
reason | shown to whoever ran zone enter. Without one wardcell supplies a generic sentence, so give one |
error | a refusal in your own words, for when you cannot reach your approver at all |
The plugin’s exit status is never read: the answer is the answer.
Scope: once and always
Section titled “Scope: once and always”scope on an approval says how far it reaches:
scope | |
|---|---|
"once" | this entry, and nothing else. The default — absent, or a word wardcell does not recognise, means this |
"always" | this entry and every later entry into the same zone that covers reaches, until the run ends. Your plugin is not asked again |
covers: how much of the command line the approval is about
Section titled “covers: how much of the command line the approval is about”npm install left-pad@1.1 and npm publish differ in their second word; git push and git push --force differ in a flag. No rule wardcell could invent about
which arguments to drop is right for both tools, and the way it would be wrong is
by granting more than the person approving was looking at. So you say how much
of an "always" approval’s command line to match, as the words a later command
line must start with:
{"approved":true,"scope":"always","covers":["npm","install"]}Asked about npm install left-pad@1.1 in release, that covers every later
npm install … into release whatever is being installed, and does not cover
npm publish. Omit covers and the approval is about exactly the command line
you were asked about — the narrowest it can be, and what a plugin that has never
heard of the field gets. "covers": [] is every command in that zone: legal,
but it has to be said outright, which is why an absent covers is never read as
it.
covers must be how the command you were asked about begins. Anything else
names a command nobody asked you about, so wardcell narrows the approval back to
that one command line and says so on stderr rather than honouring it. There are
no patterns, globs or regular expressions — a prefix of argv is checkable in one
step, and a pattern language is where “this looks narrow but matches everything”
lives.
What a standing approval is keyed on, how it is audited, and why there is deliberately
no refusing "always" are the same on every plugin — see Entry
Approval.
Waiting, and what bounds it
Section titled “Waiting, and what bounds it”approval_timeout is per zone and optional. Omit it and the entry waits as
long as the plugin takes — which is the point, for an approval that goes to a
person. Write it as a number and a unit: 90s, 30m, 4h. A bare number is
refused rather than guessed at, since "4" could mean four seconds or four
hours.
What always ends a wait, timeout or not:
| the plugin answers | obviously |
| the connection drops | every outstanding question refuses at once — a plugin that crashes does not leave entries waiting for a process that is gone |
| the run is interrupted | Ctrl-C ends the wait. Without this a question nobody answers would make the run unkillable by anything but SIGKILL |
| the run ends | the entry refuses rather than outliving the terminal it belongs to |
When wardcell stops waiting for any of those reasons it sends
{"jsonrpc":"2.0","method":"wardcell.cancel","params":{"id":"…"}}. Nothing is
expected back; it is there so a plugin holding a chat message or an open ticket
can take it down instead of leaving a question nobody will ever read the answer
to.
Be clear-eyed about the cost of an unbounded wait: the sandbox, its entry lock and the user’s terminal are all held for the whole of it, and that sandbox cannot enter another zone meanwhile.
Everything that goes wrong refuses
Section titled “Everything that goes wrong refuses”no [plugin] path configured, but a zone requires approval | the run does not start |
the plugin does not implement approvals.zone | the run does not start |
| the plugin will not start, or never binds | the run does not start |
| the connection drops | every outstanding entry refuses |
a response with no boolean approved | that entry refuses |
approval_timeout passes with no answer | that entry refuses |
Write the plugin knowing a bug in it closes the zone rather than opening it. An approval gate that opened whenever the thing guarding it was broken would be worse than no gate, because the organisation would believe it had one.
Writing one
Section titled “Writing one”Start from an example — one in Rust, one in Go, each implementing every method with the connection, the framing and the write serialisation already written. Replace one function, in the file named for it:
pub fn decide(request: &EntryRequest) -> Decision { // Answering now is a legitimate plugin: `Decision::Now { approved: true, // always: true, .. }` approves this zone and command for the rest of the run. Decision::Later { // Your own name for the request, recorded on wardcell's audit line. reference: post_to_chat(request), answer: Box::new(|request| { // Runs on its own thread. Taking an hour here costs nothing but // the entry that is waiting for it. let answered = wait_for_the_webhook(request); Decision::Now { approved: answered.ok, by: answered.who, reason: answered.why, // Which button they pressed: "Approve" or "Approve always". always: answered.for_the_rest_of_the_run, } }), }}The acknowledgement, naming the request in the response, and the lock that keeps two threads from interleaving half a line each are all in the example already.
Testing one
Section titled “Testing one”The plugin is a socket server, so nothing about wardcell is needed:
WARDCELL_PLUGIN_SOCKET=/tmp/plugin.sock ./my-plugin &printf '%s\n%s\n' \ '{"jsonrpc":"2.0","id":"1","method":"wardcell.init","params":{"run":"test","ca_cert":"","plugin_dir":"/tmp/plugin-dir"}}' \ '{"jsonrpc":"2.0","id":"2","method":"approvals.zone","params":{"run":"test","chain":["work"],"target":"release","command":["npm","publish"],"cwd":"/tmp"}}' \ | nc -U /tmp/plugin.sockTwo things worth exercising deliberately, because they are the ones people don’t:
two questions outstanding at once, answered in the reverse order — which is
what the id exists for — and what the plugin does when its own dependency is
down. That second path decides whether a broken approvals service closes your
release zone loudly or quietly.