Operator directives: an authenticated channel to a running agent
Agents authenticate machines (mTLS, capability tokens, the host-side PAT) but not humans. lever
msg, relayed email, and files arrive as unauthenticated text; the sender: "user:…" label on a
message is stamped by the orchestration layer, not verified. A hardened manager therefore refuses
out-of-band instructions from them. Operator directives are a signed, host-verified channel an agent
can treat as authoritative without becoming a prompt-injection path.
A directive is not prose: it is a signed, host-verified artifact, delivered to the agent as an opaque pointer and fetched over the agent’s own authenticated channel. The model never checks a signature; all cryptography is host-side. The rationale and threat model are in security model §11.
How it works, end to end
| Step | Actor | What happens |
|---|---|---|
| 1. Sign | operator, host-side | lever directive send asks the broker for the target’s current {cn, generation}, then builds a canonical JSON statement (target {cn, generation}, validity window, an action) and signs it with an SSH key, namespace lever-operator-directive@lever.dev. |
| 2. Submit | operator CLI → broker | The exact signed bytes go to the broker over a 0600 UNIX-domain socket in the instance state dir — unreachable from inside the jail. Every admin op (send/list/revoke/selftest) is signed regardless; only the read-only target lookup is not; the socket is defence in depth, not the trust boundary. |
| 3. Verify + store | broker, host-side | The broker verifies the signature with ssh-keygen -Y verify against allowed_signers, parses the exact received bytes (no re-serialize, duplicate JSON keys rejected), validates instance/window/target, and stores the directive active. |
| 4. Deliver a pointer | broker → agent | The agent’s inbox gets only a notice: the directive_id, the directive_consume call form and a sent-ledger ref that message_verify can check — never the action content. No directive content ever transits the message channel an attacker could also write to. |
| 4a. Preview (optional) | agent, over its own mTLS | directive_preview(id) returns the verified action without consuming it, so the agent can decide first. It is not authority; see Preview before consume. |
| 5. Consume | agent, over its own mTLS | If the agent independently decides to act, it calls directive_consume(id) on the lever-capability MCP server (the same server that mints capability tokens — see capabilities). |
| 6. Atomic CAS | broker | Returns the action only if the caller’s mTLS-verified CN + current enrolment generation match the directive’s target, it’s active, and it’s inside its time window — and flips it to consumed in the same step. Single use. |
Every failure mode — unknown id, wrong target, stale generation, already consumed, expired — returns
the same byte-identical opaque {"error":"not found"}. There is no oracle for which failure
occurred; a revoked caller gets 403, a rate-limited one 429. A read-only directive_check(id)
exists too, target-gated with the same opaque miss.
Preview before consume
directive_consume is single use, so without a preview “read the request” and “commit to it” are
the same step. directive_preview(id) separates them: the target agent reads the action, decides,
and then consumes or leaves the directive.
| Rule | Behaviour |
|---|---|
| Who | Only the target: the caller’s mTLS CN and current generation. The gate is the same as for consume. |
| Miss | Unknown id, other agent, stale generation, consumed, revoked, invalidated, expired, before not_before: the same opaque {"error":"not found"} as consume. A preview shows nothing that a consume at the same moment would refuse. |
| Content | The action, parsed from the stored signed bytes by the same validator as consume. The reply is {"id", "kind", "consumed": false, "preview": {…action…}, "expires_at", "previews_remaining", "note"}. |
| Authority | None. The content is under preview, never under action or advisory_text, and the note says the directive is not consumed. The reply holds no token and no grant. Only a directive_consume result is operator authority. |
| State | A preview does not consume. The directive stays active and a later consume succeeds exactly once. Only the preview count changes. |
| Cap | 5 previews per directive (persisted). After that the target gets 429 {"error":"preview limit reached: …"}; other callers still get the opaque miss. A capped directive is still consumable. Previews share the per-agent directive rate limit. |
| Audit | Each preview writes previewed (caller, id, kind, count) to directives.log; a refused lookup (no active match for this caller) writes preview_denied, a capped call preview_capped; a malformed, rate-limited or disabled call is in broker.log only. The consumed line records how many previews came first, and lever directive list shows previews. |
The tool is in the agent image’s lever-agent binary: rebuild the agent image to get it.
Identity binding. A directive targets {cn, generation}, not a recyclable agent slug; consume
requires the caller’s live CN and current generation to match
(§11.4).
Directive state (active/consumed/revoked/invalidated/expired, plus tombstones for replay defence,
plus per-CN generations) persists to .lever-state/directives.json with atomic writes, and
consume/submit/preview fail closed on a persistence error rather than report a success that isn’t durable.
invalidated is set on an active directive when its target CN re-enrols (generation bump).
This depends on per-agent network-namespace isolation; see worker isolation §4.3.
Directive kinds
| Kind | Shape | Trust level |
|---|---|---|
tool_call |
Fully bound: tool, op, args all fixed at signing (arg_binding: "exact", uses: 1). Injected text has nothing to steer — the arguments aren’t there to steer. |
Authenticated intent (not call-time enforced). |
approval |
Permits a call the agent’s standing grants already allow but policy flagged for operator sign-off. Never elevates beyond standing grants. Preferred shape. | Its distinguishing enforcement (an operator-approval gate at call time) is deferred to a later phase. |
instruction |
Free-text advisory guidance. No host enforcement at all. | Explicitly lower-trust — the bootstrap treats it as the operator’s steer, never as authority that overrides a refusal of a sensitive/outbound action. The escape hatch; keep it rare. |
Scope
The mechanism delivers authenticated, replay-proof delivery and verification of an operator-signed
action bound to the target agent. It does not enforce the bound action at tool-call time: the
agent makes the call through its ordinary capability path, under the existing grant checks, so a
directive grants no new capability. For tool_call and approval, the execution boundary is model discipline, not a host-enforced gate. See
§11.1.
Setup
The operator: config block
operator:
allowed_signers: operator_allowed_signers # ssh-keygen allowed_signers file; keep it OUT of tree:
signing_key: /abs/path/to/operator # default private key `lever directive send` signs with
directive_expiry: 10m # optional; default 10m
directive_expiry_max: 24h # optional; default 24h (hard cap — a larger value is rejected)
The whole block is optional. With allowed_signers unset, the channel is simply disabled —
dormant, not a half-configured risk. allowed_signers is confined to the instance root (like
manager.prompt_file); signing_key is a host path and deliberately not confined — the signing
key must live outside the mounted tree, where a jailed agent can never read it. Put both at the
host-only instance root, outside tree:, and gitignore the private key.
Generating a key
ssh-keygen -t ed25519 -f operator # writes operator (private) + operator.pub
Add a line to the allowed_signers file naming the fixed principal operator@<instance-name>:
operator@myapp ssh-ed25519 AAAA... # <type> <keydata> copied from operator.pub
Verify the wiring before you need it in anger:
lever directive selftest
# selftest OK: signing key verifies against the broker's allowed_signers
Key posture
Keep the key on the operator’s own machine or hardware-backed; never forward an SSH agent to the
broker host; put two or more keys under the operator@<instance> principal so a lost key never
locks you out. Editing allowed_signers takes effect immediately (the broker runs ssh-keygen -Y
verify per call). Detail: §11.5.
Using it
# advisory instruction — no host enforcement, keep it rare
lever directive send manager --instruction "Hold all outbound email until I'm back online at 6pm."
# fully-bound tool call — args fixed at signing time
lever directive send worker-a --action '{"kind":"tool_call","tool":"db","op":"read","args":{"table":"orders"},"arg_binding":"exact","uses":1}'
# list what's outstanding
lever directive list --state active
# revoke one before it's consumed
lever directive revoke <directive-id>
send prints the exact statement bytes it’s about to sign, for operator review, before it sends
anything. <agent> is the manager (manager or the app name) or a declared worker name; --expires defaults to
operator.directive_expiry and is hard-capped at operator.directive_expiry_max (itself capped at
24h). The lifetime counts from the send time, not from --not-before. --key PATH overrides
operator.signing_key (on send/list/revoke/selftest). --not-before RFC3339 sets the start of
validity (default: now); a start more than 2 minutes in the future is refused by lever directive
send before it contacts the broker (the broker enforces the same limit), so the flag only absorbs
clock skew. --state on list accepts
active|consumed|revoked|invalidated|expired. Every directive subcommand takes an optional
trailing [CONFIG] path and otherwise reads ./lever.yaml, like the other lever commands.
The broker refuses a send, and stores nothing, when the target is not enrolled yet, when the
target is not running (bring it up and send again), or when a tool_call/approval names a tool
the broker does not know. If the notice does not reach the agent, the broker revokes the directive
and send exits non-zero with the next step to take.
lever doctor reports an “operator directives” check: unconfigured is a pass (most instances never
touch this), and once configured it verifies allowed_signers has at least one key, ssh-keygen is
on PATH, and — if the broker is up — that the directive socket exists.
What this does NOT do
- It does not grant any capability the target did not already hold (see Scope).
instructiondirectives authenticate only that the operator sent them; they never override a refusal of a sensitive or outbound action.- It does not authenticate manager→worker messages. A directive for a worker is signed for that worker directly; authority does not launder through the manager.
- It does not hide directive ids from a compromised hub; ids are public. It protects the content and the single-use, target-bound consumption.
Threat model: security model §11. Config keys: config reference. Commands: CLI reference.