Skip to content

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.

MethodDecidesAskedState
credentials.resolvewhich identity an outbound request carriesper connection, and per requestbuilt
approvals.zonewhether this entry into a zone happensonce per zone enter into a gated zonebuilt
approvals.networkwhether to allow a host or request no grant coversper blocked connection or requestbuilt
discovery.resolve_addrwhat service an address belongs toonce per unnamed flow the policy would refusebuilt
discovery.resolve_namewhere a service’s instances areonce per destination allowed by namebuilt

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.

/etc/wardcell/config.toml — the machine says which program
[plugin]
path = "/usr/local/libexec/wardcell/wardcell-plugin"
zones.toml — the policy says what is needed of it
[zones.user]
profile = "developer"
authenticated = true # needs credentials.resolve
[zones.release]
profile = "release"
enter_from = ["work"]
requires_approval = true # needs approvals.zone

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

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.

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.cancel naming the id, 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:

MethodA broken plugin
credentials.resolvefails 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.zonefails 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_addrfails 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_namefalls 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.

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.

Both are laid out the same way, and two of the four parts are yours to replace:

mainthe socket and the routing
credentialsone function: what identity a request to a host carries
approvalone function: whether an entry into a zone happens
discoverytwo functions: what service an address belongs to, and where a service is
typeswhat 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.