Appearance
The Cockpit Card
A named human underwrites one agent, for a declared scope. The agent does the work; the human carries the liability. Authority is an intersection recomputed on every request, and a confirmation is a fingerprint unlocking a key that signs the exact facts.
Why It Matters
Agent Access Control answers what may this agent do. It does not answer the question that a procurement department, a finance team or a regulator asks first: when this agent signs something expensive and wrong, who is liable?
Not the agent. An agent owns nothing, holds no assets and cannot be sued. For most automation that is irrelevant. For the cases where it is not — procurement, finance, large transfers, anything an operator wants a name attached to — a Cockpit Card binds one developer to one agent for a declared scope.
The card stores no authority. Storing authority is how you end up with a credential that outlives the permission behind it. What a card conveys is an intersection, re-derived on every single request:
effective = agent_own_rbac
∪ (delegated(dev) ∩ dev_authority_now ∩ mandate.scope)
∩ capability_caveats
∩ mandate.limitsRemove a permission from the human and every mandate leaning on it conveys less on the next call — not at the next token refresh. Suspend the human and the card conveys nothing, which needed no code: an empty set intersected with anything is empty. This is deliberately the same issuer-current clamp that makes capability tokens safe, applied one level up.
What Cannot Be Delegated
A mandate lets an agent do work. It never lets an agent hand out authority. Two permissions are permanently outside the delegatable set, by construction rather than by policy:
| Permission | Why it is never delegatable |
|---|---|
authz.delegate | Issuing capability tokens. A mandate that could delegate would let an agent widen its own reach through a second hop. |
contract.template.publish | Publishing a form other parties sign. Authorship is an accountability position, not a task. |
Scope is validated against the human's live flags when the mandate is issued and re-intersected when it is used, so a mandate can never manufacture authority it was not given. The agent's own access-control ceiling still binds on top.
Presence: The Fingerprint Must Sign
Above a threshold the phone asks. The distinction that makes this worth building rather than shipping a push notification:
A tapped Approve proves someone was holding an unlocked phone. That is not the claim we want to make.
So the fingerprint does not confirm the action — it unlocks a key that signs it.
The device holds a WebAuthn platform credential created with userVerification: "required", living in the phone's hardware keystore (Secure Enclave, StrongBox). The operating system refuses to produce a signature without a fingerprint or the device PIN, and records that it happened in the UV bit inside the signed authenticator data. That bit is set by the authenticator, not by the page, and it is covered by the signature — so the server verifies presence rather than trusting a claim. An attacker who has fully compromised the browser still cannot fabricate a human.
Design note, learned the hard way. The first implementation used a non-extractable WebCrypto key. That proves a key cannot be stolen, but nothing in WebCrypto requires a human to be present when it is used — it signs silently. Unstealable is not the same property as present. WebAuthn is the primitive that enforces the one we actually needed.
What is signed is sha256(canonical(facts) ‖ nonce):
- the signature cannot be replayed onto a different action, because the facts are inside the hash;
- the phone can only render a summary the server generated, so the screen and the signature always agree;
- challenges are single-use with a short window, and expiry counts as a denial — a confirmation that waits patiently for three days is a queue, not a confirmation.
If the device key is revoked, the fallback is an authenticator code. The evidence records which method was actually achieved (webauthn_uv, enclave, or totp) so the record never quietly downgrades and then describes itself in the stronger terms.
Legal scope, stated honestly. Under eIDAS this is an advanced electronic signature, not a qualified one. The defensible claim is narrower and still useful: this human, on this enrolled device, authorised these exact figures, and here is the chain that proves it.
Sign-In With The Same Device
Once a device can prove a human is present, it can also stand at the front door. With push login enabled, a correct password is no longer sufficient: the browser receives an approval reference and no token at all until the enrolled device signs.
- The approval endpoint requires no session. On a phone the device that must approve is often the device signing in; demanding a session first is a deadlock. A signature over our challenge, from a credential this account enrolled, with the presence bit set, is stronger evidence than a bearer token — the assertion is the authentication.
- The requesting address is shown before the fingerprint is requested, so "is this you" can be answered no.
- Non-interactive callers are refused, not queued. Scripts, canaries and health checks that log in with a password get a straight
401telling them to use an API key. Queuing them produced approval notifications indistinguishable from the human's own — and a security control whose notifications you learn to swipe away has not added a factor, it has trained you to dismiss one.
Evidence
Every authority-relevant event appends to a per-mandate hash chain:
chain = sha256(prev_hash ‖ digest) UNIQUE(mandate_id, seq)Each link carries an authority_snapshot: what the human's permissions were at that instant. This is the part that is not decoration. A year later the auditor's question is never "what can this account do" — it is "what could it do on the fourteenth of March, and who said so". An intersection is unauditable after the fact unless you wrote down both sides at the time.
Tampering with a stored payload breaks the recompute and the chain reports chain_ok=false at the exact link. Actions also emit to the security audit feed under their own liability. prefix, separate from agent-access events.
Modes
LIABILITY_MODE is a three-position switch, default off:
| Mode | Behaviour |
|---|---|
off | Endpoints return 404. Byte-inert. |
shadow | Decisions computed and logged; nothing is blocked. |
enforce | A failed gate returns 403 and commits gate-block evidence. |
The gate is deny-only and pre-escrow, so it can never move value and is irrelevant to the supply invariant.
API
| Endpoint | Purpose |
|---|---|
GET /liability/delegatable | What the caller's live authority is allowed to underwrite |
POST /liability/mandates | Issue a mandate (presence-gated) |
GET /liability/mandates/mine | My mandates, with resolved effective scope |
POST /liability/mandates/{id}/revoke | Instant clamp |
POST /liability/mandates/{id}/verify | Re-derive and verify the evidence chain |
POST /liability/challenges | Raise a confirmation for an action |
POST /liability/challenges/{id}/approve | Approve with a WebAuthn assertion (or TOTP fallback) |
GET /liability/webauthn/options · POST /liability/webauthn/register | Enrol this device's credential |
Where to find it in the app: /cockpit for mandates and evidence; Pocket → Approvals to enrol a device key, see pending confirmations, and freeze every delegated authority at once.