Skip to content

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.

/etc/wardcell/config.toml
[plugin]
path = "/usr/local/libexec/wardcell/wardcell-plugin"
zones.toml
[zones.release]
profile = "release"
enter_from = ["work"]
requires_approval = true
approval_timeout = "4h" # optional; omit to wait as long as it takes

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

  1. wardcell picks a socket path and execs the plugin once per run, with WARDCELL_PLUGIN_SOCKET set to it. Nothing else is passed.
  2. The plugin binds and listens. wardcell is the client and connects once.
  3. A plugin that has not bound within 10 seconds, or that does not answer the wardcell.init handshake, or that does not implement approvals.zone when a zone requires it, fails the run’s startup — rather than failing the first gated entry for a reason unrelated to it.
  4. The plugin serves until the run ends, and is killed with it.
{"jsonrpc":"2.0","id":"9f2c4a…-0","method":"approvals.zone","params":{"run":"9f2c4a…","chain":["work"],"target":"release","command":["npm","publish"],"cwd":"/Users/me/app"}}
idminted by wardcell. The only thing that ties an answer to a question — name it in the response
runthe run this entry belongs to. Also the trace id in its telemetry
chainhow the asking process got here: the zone its run started in, then each zone entered since. A one-element chain has not escalated yet
targetthe zone it wants to enter
commandwhat it wants to run there, as argv
cwdwhere 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.

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.pendingoptional, 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
approvedmust be the boolean true. A string "true", a missing field, or anything else refuses — the one field that decides this is never defaulted
byoptional, 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
coversoptional, 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
reasonshown to whoever ran zone enter. Without one wardcell supplies a generic sentence, so give one
errora 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 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.

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 answersobviously
the connection dropsevery outstanding question refuses at once — a plugin that crashes does not leave entries waiting for a process that is gone
the run is interruptedCtrl-C ends the wait. Without this a question nobody answers would make the run unkillable by anything but SIGKILL
the run endsthe 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.

no [plugin] path configured, but a zone requires approvalthe run does not start
the plugin does not implement approvals.zonethe run does not start
the plugin will not start, or never bindsthe run does not start
the connection dropsevery outstanding entry refuses
a response with no boolean approvedthat entry refuses
approval_timeout passes with no answerthat 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.

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:

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

The plugin is a socket server, so nothing about wardcell is needed:

Terminal window
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.sock

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