How wardcell Calls Out
wardcell does not know about your secrets manager or your approval process, and it is not going to learn. It asks a program you provide, and the answer decides what happens next.
One plugin, one socket, one process per run. Not one per question: a single program answers everything wardcell asks, over a single connection that lasts the run.
| Method | Decides | Asked | State |
|---|---|---|---|
credentials.resolve | which identity an outbound request carries | per connection, and per request | built |
approvals.zone | whether this entry into a zone happens | once per zone enter into a gated zone | built |
approvals.network | whether to allow a host or request no grant covers | per blocked connection or request | built |
discovery.resolve_addr | what service an address belongs to | once per unnamed flow the policy would refuse | built |
discovery.resolve_name | where a service’s instances are | once per destination allowed by name | built |
Named by the machine, required by the policy
Section titled “Named by the machine, required by the policy”Two files say what a run needs of a plugin and where to find one, and neither says both.
[plugin]path = "/usr/local/libexec/wardcell/wardcell-plugin"[zones.user]profile = "developer"authenticated = true # needs credentials.resolve
[zones.release]profile = "release"enter_from = ["work"]requires_approval = true # needs approvals.zoneNo zone names a plugin. A policy root is the artifact an organisation distributes and versions; which secret store holds its tokens and where its approvals go are properties of the fleet it is deployed to. Split this way, one policy root serves machines wired to entirely different programs — and the plugin is not only about zones, so a machine with no zones at all still has somewhere to name one.
Both files are root-owned. A plugin path a user could edit would be a way to choose which program answers a security question, which is the opposite of the point.
What a plugin can do is discovered, not configured
Section titled “What a plugin can do is discovered, not configured”The first thing wardcell asks is wardcell.init. It hands the plugin what it needs
to anchor trust and write files for the run — the run’s id, its per-run CA
certificate, and a shared directory to materialize files into — and the plugin
answers with the methods it implements:
{"jsonrpc": "2.0", "id": "0191…-0", "method": "wardcell.init", "params": { "run": "0191e7…", "ca_cert": "-----BEGIN CERTIFICATE-----\n…", "plugin_dir": "/private/tmp/wardcell-…-plugin"}}{"jsonrpc": "2.0", "id": "0191…-0", "result": {"features": { "credentials.resolve": {}, "approvals.zone": {}, "approvals.network": {}, "discovery.resolve_addr": {}, "discovery.resolve_name": {}}}}ca_cert is the run’s CA — the public certificate, never the key — and every
certificate wardcell presents is signed under it, so a bespoke client trusts the
whole run by trusting this one anchor. plugin_dir is a directory wardcell
creates for the run: the plugin writes trust material and other files there on
the host, and the sandbox reads them read-only through
$WARDCELL_PLUGIN_DIR. wardcell waits for the reply
before it runs the sandboxed command, so whatever the plugin writes during
init is there before first use.
Nothing is declared twice. One program serving both methods and two programs serving one each are the same arrangement from wardcell’s side, told apart only by what each says it can do.
The policy decides whether that is enough, before the run starts:
error: zone 'release' sets requires_approval = true, needing 'approvals.zone', which the plugin '/usr/local/libexec/wardcell/wardcell-plugin' does not implement. It implements: credentials.resolve.Every unmet requirement at once, each naming the zone and the line that asked for it. The alternative is a run that gets as far as its first gated entry and can then do nothing but refuse it.
The machine decides whether; the policy decides what
Section titled “The machine decides whether; the policy decides what”A configured plugin starts for every run, whatever the policy asks of it.
Naming a program in /etc/wardcell/config.toml is what runs it; an operator who
wants no plugin names none.
zones.toml is an assertion about that program, not a trigger for it. It says
what the plugin must be able to answer, and the check above is where saying so
pays off.
That split matters because not every method has a policy setting that could ask
for it. Nothing in a fragment marks a grant as
resolver-backed — by design — so there is no
zones.toml line that could make service discovery a requirement. Those methods
are used because the plugin says it implements them, which only works if the
plugin is running in the first place.
What they have in common
Section titled “What they have in common”Any executable. A plugin is a path in a config file. Nothing links against wardcell, nothing imports a library, and the language is yours.
JSON-RPC 2.0, one object per line. In both directions, over a stream. A plugin that can read a line and print a line can implement every method.
Nothing secret in argv. Arguments and environment are visible to every
process on the machine. wardcell passes a socket path in WARDCELL_PLUGIN_SOCKET,
nothing more; everything about the question travels over the stream.
Run outside the sandbox, as the user who started the run. A plugin is wardcell’s own helper, not part of the sandboxed process, and no policy rule governs what it can reach. Reaching the network, a keychain or a device is between the plugin and the operating system.
Bind within ten seconds, and stay running. The budget is for a program that needs time to exec and bind; a plugin that exits without binding is reported the moment it exits, naming its exit status, rather than waiting the budget out. Its own stdout and stderr go to the run’s stderr, so whatever it printed on the way down is already in front of whoever started the run. Do not daemonise: wardcell ends the process it started when the run ends, so a plugin whose parent exits leaves a child nothing stops.
Why id is the whole design
Section titled “Why id is the whole design”A question and its answer are separate messages joined by id. That one property
is what lets the same protocol carry a credential fetched in 40 ms and an approval
that waits four hours in front of a person:
- Answers may come back in any order. A slow question holds nothing but itself — not the connection, not the other questions on it, not a thread.
- A late answer is ordinary. There is no acknowledge-now-answer-later handshake to learn, because a response that arrives four hours later is the same message as one that arrives immediately.
- Giving up says so. When wardcell stops waiting — a timeout, an interrupted
run — it sends
wardcell.cancelnaming theid, so a plugin holding a chat message open can take it down.
A plugin that wants to say “I have this, and here is my name for it” sends an
wardcell.pending notification. It is recorded on the audit line and decides
nothing.
An approval also says how far it reaches: scope: "once" for this entry, or
"always" for every later entry into the same zone with a command line the
approval covers — the command itself by default, or as much of it as the plugin
says. That is the “approve, and stop asking me” of a chat prompt with more than one
button, and wardcell is what remembers it, for the life of the run. See
Approval Plugins.
What failure means is part of the contract
Section titled “What failure means is part of the contract”Both methods decide something, so both fail the same way, and it is not a detail to discover later:
| Method | A broken plugin |
|---|---|
credentials.resolve | fails closed. The request does not leave and the caller gets 502. Sending it anyway would forward whatever header the sandboxed process set in place of the identity the zone promised. |
approvals.zone | fails closed. The entry is refused. A gate that opens when the thing guarding it is broken is worse than no gate, because the organisation believes it has one. |
discovery.resolve_addr | fails closed. The flow is left with no name, so only an address rule can match it — which, for the private addresses this exists to reach, means refused. |
discovery.resolve_name | falls back to the host’s own resolver, since a broken registry is no reason to refuse an ordinary name. Whatever that resolves to is vetted as strictly as ever, and if it cannot resolve either, the error names the registry failure. |
In both cases, “nothing is needed here” and “this is broken” are separate
answers with separate encodings: a result and an error. A plugin says the
first deliberately; it never says it by failing.
The examples
Section titled “The examples”Working plugins live in the repository, outside the Cargo workspace, because a real plugin has no dependency on wardcell. Two starting points, one per language, each implementing every method — so whichever one you need, the transport, framing and error handling are written and what is left is the decision itself.
examples/rust-plugin/— no dependencies at all.examples/go-plugin/— standard library only.
Both are laid out the same way, and two of the four parts are yours to replace:
main | the socket and the routing |
credentials | one function: what identity a request to a host carries |
approval | one function: whether an entry into a zone happens |
discovery | two functions: what service an address belongs to, and where a service is |
types | what the protocol carries, and how it goes on and off the wire |
Implement one method and not the other by removing it from FEATURES. A policy
that needs what you removed then fails before its run starts, saying so.