Human gates
A gated tools/call pauses at the MCP gate until a human decides. Two channels can answer (the terminal prompt and a signed webhook decision) and the first decision wins.
On this page
- Declaring a gate
- What happens at run time
- The terminal prompt
- The signed webhook decision
- Fail closed, and where it doesn't
- In the audit log
- Webhook specification
- Changelog
- 1. Purpose
- 2. Trust model
- 3. Agentfile field
- 4. Request: Constle → decision endpoint
- 4.1 Delivery mechanism
- 5. Canonical subject encoding
- 6. Response: decision endpoint → Constle
- 7. Verification (Constle side)
- 8. Fail-closed behavior
- 9. Audit log
- 10. Known limitations
Transcript
0:00 Some tool calls are too consequential to leave to an agent. Constle can make them wait for a human. List the exact tool name in human_gates. When the agent calls it, the MCP gate holds the call — outside the sandbox. At the terminal you see the agent, the tool, the subject digest and every argument. Approve, or deny. Or decide remotely. Constle posts the request to your webhook, then polls for a decision signed with your Ed25519 key. The signature must verify against approver_pubkey, and the request id and subject digest must match. Only then does the call go through. A bad signature: denied. A replayed decision for another request: denied. No answer at all: on_timeout decides — and it defaults to abort.
0:52 A person stays in the loop — where the agent can't reach.
Declaring a gateLink to this section
mcp:
servers:
- id: accounting
url: https://mcp.accounting.internal # host-side only
tools: [list_invoices, pay_invoice]
human_gates:
enabled: true # the master switch; the default is false
require_approval_for:
- pay_invoice # an exact MCP tool name
approver_pubkey: did:key:z6Mk… # from `constle webhook-keygen`; required when gating
approval_timeout_seconds: 300 # the default
on_timeout: abort # the default: refuse the call and stop the run
notify:
- channel: webhook
url_secret_ref: HUMAN_GATE_WEBHOOK_URL # the env var holding the URLenableddefaults tofalse, and then nothing is gated even ifrequire_approval_forlists entries.validateandrunwarn about disarmed entries.require_approval_forentries are exact, case-sensitive MCP tool names: theparams.nameof atools/callon a server declared undermcp.servers. No patterns, no semantic matching. An entry that can match no declared tool is reported at validate and run time. With nomcp.serversdeclared, nothing is gated. This is limitation 1.approver_pubkeyis required wheneverrequire_approval_foris non-empty. It is adid:keyfor an approver keypair made withconstle webhook-keygen, which is not an agent identity: it authenticates the human deciding, not the agent asking.notifynames the environment variable that holds the webhook URL, so the URL never sits in the Agentfile. An unsupported channel is a validation error.
Gates cover MCP tool calls only. Plain HTTPS through allowed_hosts never passes the gate, and there is no gate on filesystem writes (there is no chokepoint outside the sandbox for them yet; spec §14.3).
What happens at run timeLink to this section
When a gated call arrives, the gate pauses it, writes a gate_triggered audit event, notifies every resolved webhook URL, and waits for a decision, recorded as gate_approved, gate_denied or gate_timeout. An approved call is then forwarded like any other, bracketed by tool_call_start / tool_call_end events.
The terminal promptLink to this section
⏸ human gate: agent "invoice-processor" wants to call MCP tool "pay_invoice" on server "accounting"
subject: sha256:4b3f…e91a
arguments (25 bytes, 1 lines) — shown in full:
{"invoice_id":"INV-0042"}
approve? [a]pprove / [d]eny (timeout 300s → abort):A terminal approval is an unsigned local operator action, recorded with decided_by: terminal. Arguments too large to show in full can be denied at the terminal but never approved there. When stdin is not a terminal (CI, a pipe, a backgrounded run) the gate says so once and waits for the deadline, so on_timeout decides; it never blocks forever on a read that cannot resolve, and never treats "nobody is watching" as approval.
The signed webhook decisionLink to this section
With approver_pubkey set and a notify URL resolving, Constle also POSTs the request to your endpoint and polls GET <url>/<request_id>/decision for an answer. The decision must be Ed25519-signed by the approver key over request_id + "." + decision + "." + subject_digest, and Constle checks, in order:
- Decode
approver_pubkeyfrom the Agentfile. - Rebuild the signed payload from the response fields.
- Verify the signature with that key.
- Confirm
request_idis the one minted for this gate (so one "yes" can't answer an identical later call). - Confirm
subject_digestis the one sent (the hash of the exact tool call). - Only then, and only if
decision == "approved", forward the call.
The full wire protocol is below, under Webhook specification.
Fail closed, and where it doesn'tLink to this section
A decision that arrives is verified and can only deny. No setting relaxes this:
| A decision that arrives | Result |
|---|---|
approver_pubkey missing, or not a valid did:key |
constle validate fails |
| Signature doesn't verify | denied · gate_signature_invalid |
request_id mismatch |
denied · gate_request_id_mismatch |
subject_digest mismatch |
denied · gate_digest_mismatch |
Anything but "approved" |
denied |
No decision at all by the deadline is resolved by policy, not by a guarantee. on_timeout decides, and both values are legal:
on_timeout |
Result |
|---|---|
abort (the default) |
The call is refused and the run is terminated · gate_timeout |
proceed |
The call is forwarded without approval · gate_timeout |
An unreachable endpoint, an endpoint that answers 200 with a body that doesn't parse as a decision, and an approver_pubkey with no resolvable notify URL all count as no decision. A broken webhook therefore fails toward whatever on_timeout names. Under proceed, a gate delays a call rather than blocking it.
Limits of the signed channel
- No rotation or revocation: a compromised approver key stays valid until the Agentfile changes.
- One approver per agent. No M-of-N.
- Ed25519 removes host-side forgery, not host-side coercion: the host could lie about what it is asking.
- A racing terminal prompt can approve without any signature (
decided_by: terminal). subject_digestis unsalted SHA-256: guessable arguments can be recovered from the log.
In the audit logLink to this section
Every terminal event of a gate decided over the webhook carries the decision that produced it, the identifying fields of the request (never its arguments) and the approver key actually used. constle audit verify --agentfile=<path> re-verifies each recorded decision against the Agentfile's approver_pubkey, so an approval the approver never signed cannot be recorded as though they had. See Audit log and verification.
Webhook specificationLink to this section
The specification below is spec/human-gates-webhook.md from the Constle repository, reproduced verbatim.
ChangelogLink to this section
- 0.4.0 (2026-09-21): §9 rewritten. Every version up to 0.3.0 promised that the signed decision was persisted and re-verifiable offline, and the runtime persisted none of it —
gate_approvedrecorded that something had been approved and nothing that showed the approver had approved it. The decision is now written as its signed fields rather than as the raw response body (equivalent for a §6 signature, which covers a derived string, and bounded where the body is not); the recorded request deliberately omitstool_call.arguments; theapprover_pubkeyactually verified against is recorded alongside; andconstle audit verify --agentfile=<path>(or--approver-pubkey=<did:key:…>) re-verifies the result. §9 also states which fields the 256-byte bound actually covers and that only approvals are required to carry proof, and §10 records the relabelling limitation that excluding the arguments leaves open. - 0.3.1 (2026-09-21): Documentation accuracy only — no change to the wire format, the verification steps, or any runtime behaviour. §8 previously read as an unconditional "fail closed, always". It is now split into what holds unconditionally (§8.1: a decision that arrives is verified and can only deny), what is operator policy rather than a guarantee (§8.2: a gate that receives no decision is resolved by
human_gates.on_timeout, which may legally beproceed), and what this channel does not cover at all (§8.3: a racing terminal prompt approves without signing). §4's timeout bullet and §4.1's poll table are corrected to match: a200whose body does not parse as a decision object continues polling rather than denying. - 0.3.0 (2026-09-04): Resolved the two questions 0.2.0 left open. §4.1 (new) specifies the delivery mechanism previously deferred as "out of scope for this revision": POST-once-then-poll against the same URL
human_gates.notifyalready uses, with a derivable per-request decision endpoint. §5 canonicalization is now implemented (internal/humangate.SubjectDigest) rather than merely specified, with its two documented, deliberate deviations from strict RFC 8785 noted inline. - 0.2.0 (2026-09-04): Replaced HMAC-SHA256 symmetric signing with Ed25519 asymmetric signatures. Introduced a dedicated webhook signing keypair, decoupled from
internal/identity's per-agent DIDs. Public key now declared in the Agentfile as adid:keystring. Fail-closed behavior for a missing or malformed key made explicit. - 0.1.0 (2026-08-28): Initial draft. HMAC-SHA256 signing (Stripe-style), subject-digest binding, signed-decision verbatim logging.
1. PurposeLink to this section
When human_gates.require_approval_for names an MCP tool call that Constle intercepts, the runtime needs a way to ask an external decision-maker — a human, not another agent — whether the call should proceed. This spec defines that channel: the wire format of the request Constle sends out, the wire format of the decision that comes back, and how Constle verifies the decision actually came from the party the Agentfile designates as the approver.
It does not define how the approver's UI collects the decision. That's implementation-specific (Slack button, web form, CLI prompt) and out of scope.
2. Trust modelLink to this section
Constle's identity system (internal/identity, pkg/did) authenticates agents, not people. identity.Create() issues one Ed25519 keypair per agent, keyed by agent name. There is no existing concept of a human-held key, and the Owner field elsewhere in the Agentfile is a free-text label — it is checked against what's written, not cryptographically bound to anything.
This spec introduces a separate keypair, scoped only to the human-gates webhook flow. It is not an agent identity, is not created by identity.Create(), and is not tracked anywhere internal/identity looks. Reusing an agent's DID for a human approver would conflate "the agent that's boxed" with "the person approving what it does" — a fusion this spec deliberately avoids.
The webhook's public key is encoded as a did:key string, using the same multicodec/Ed25519 wire format pkg/did already decodes. This is a formatting-convenience decision, not a model decision: pkg/did's decoder is generic to any Ed25519 public key regardless of who holds it, so reusing it here costs zero new code — but it does not imply the webhook keypair is, or becomes, an agent identity.
3. Agentfile fieldLink to this section
human_gates:
require_approval_for:
- "fs.write"
- "network.request"
approver_pubkey: "did:key:z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2PKGNCKVtZxP" # example valueapprover_pubkeyis required wheneverrequire_approval_foris non-empty. An Agentfile that declares gated tools without anapprover_pubkeyfailsconstle validate.- Format: a
did:keymultibase string encoding a single Ed25519 public key (32 bytes), decoded viapkg/did.Decode(). - Rotation is manual: changing the approver means editing this field and redeploying. There is no registry or discovery mechanism (see §9).
4. Request: Constle → decision endpointLink to this section
{
"request_id": "hg_7f3a9c2e",
"agent_name": "invoice-processor",
"tool_call": {
"name": "fs.write",
"arguments": { "path": "/data/out/report.csv", "content": "..." }
},
"subject_digest": "sha256:4b3f...e91a",
"timestamp": "2026-09-04T14:22:03Z"
}subject_digestis SHA-256 over the exact, canonical byte representation oftool_call(§5) — this is what the approver is actually signing off on, byte for byte.- No response within the configured timeout is not a denial in itself. The gate has nothing to verify, so
human_gates.on_timeoutdecides:abort(the default) refuses the call and stops the run,proceedforwards it unapproved. See §8.2. - Constle MAY include additional fields beyond the five above —
run_id,approval_timeout_seconds,timeout_at,on_timeout— for a receiver's own bookkeeping (a countdown display, knowing when to give up holding a gate open). These carry no cryptographic weight: the signed statement in §6 is exactlyrequest_id + "." + decision + "." + subject_digest, nothing else. A receiver MUST NOT require them; one that only implements the five fields above still works correctly end to end.
4.1 Delivery mechanismLink to this section
Constle POSTs the §4 request to the URL configured via human_gates.notify (channel: webhook, url_secret_ref) — the same URL that already receives gate-triggered notifications; there is no separate URL to configure for decisions. Where several notify entries resolve to several URLs, trigger notifications fan out to all of them but the decision channel is the first resolved URL only: one gate has one decision endpoint, and it is not the case that any declared receiver may answer. The receiver acknowledges with any 2xx status. request_id is the idempotency key: a receiver MUST treat a repeated POST carrying the same request_id as a retry of the same gate, never as a new one.
The decision is fetched by polling GET <configured URL>/<request_id>/decision — derivable from the configured URL and request_id alone, so a receiver that never saw the POST (or whose 2xx response was lost in transit) still exposes a discoverable decision endpoint once it learns about the gate by whatever means. Poll responses:
| Response | Meaning |
|---|---|
200 with a body that parses as a §6 decision object |
Decided. Constle verifies it per §7 and stops polling either way — an invalid decision denies the call (§8.1); it does not fall back to continued polling. |
200 with a body that does not parse as a decision object |
Not yet decided. Parsing is what makes a response a decision at all, so a body Constle cannot decode is indistinguishable from "no answer yet" and polling continues. An endpoint that answers 200 with a permanently malformed body therefore resolves as a timeout (§8.2), not as a denial. A body that parses but is empty or unsigned — {}, null, a decision object with no signature — is a decision, and denies. |
anything else (202, 404, 5xx, connection failure, timeout, …) |
Not yet decided. Constle retries the POST (if not yet acknowledged) and re-polls, on a fixed interval, until a decision arrives or the gate's timeout elapses. |
A receiver MAY hold the GET open before answering, as a latency optimization — Constle neither requests nor requires this; it simply polls again on its own schedule regardless.
This endpoint is one input to a gate, not necessarily the only one. When Constle is also running an interactive terminal prompt for the same gate, both channels are live simultaneously and the first to produce a decision wins; the loser's context is then canceled. Nothing in this spec makes the decision endpoint authoritative over a local operator, and §8.3 states what that costs.
Every outbound request is bound by the gate's own approval_timeout_seconds deadline, computed once when the gate opens. No single request, retry, or poll extends a decision's validity past that deadline, and no response arriving after it is honored — unchanged from the existing timeout behavior.
5. Canonical subject encodingLink to this section
subject_digest must be independently reproducible on both sides, or the binding in §6 is meaningless:
tool_callserialized as JSON with sorted object keys, no whitespace (RFC 8785 JCS-style canonicalization).- UTF-8 encoded.
- SHA-256 of the resulting bytes, hex-encoded, prefixed
sha256:.
Constle computes this once when building the request. The decision endpoint doesn't have to recompute it to respond — but should, to confirm it's approving what it thinks it's approving (§6).
Note — this is a second canonicalization convention, not a reuse of the existing one. The codebase's two existing signed-payload flows (
audit.Entryininternal/audit/logger.go, anda2a.Envelopeininternal/a2a/envelope.go) both deliberately avoid re-canonicalization: they sign/verify over the exact wire bytes produced by a singleencoding/json.Marshalcall, with the signature field declared last and stripped by byte-offset rather than by re-serializing.envelope.gostates this explicitly as a design choice ("no re-canonicalization, so verification is over the very bytes that traveled"). RFC 8785 JCS is the opposite strategy: both sides independently re-derive canonical bytes from a parsed structure, which only holds if both implementations produce identical output (sorted keys, number formatting, escaping) for every value intool_call.arguments— a guaranteeencoding/jsondoes not provide out of the box and Go's stdlib has no built-in JCS encoder for. This isn't a hard conflict —tool_callis a fresh object, not a shared struct with the other two flows — but it does mean the codebase would carry two different canonicalization philosophies for adjacent problems. Worth a deliberate call before implementation, not an accretion by default.
Implemented as internal/humangate.SubjectDigest, "-style" rather than a strict RFC 8785 encoder, on the strength of two guarantees encoding/json already provides: Marshal always emits map keys in sorted order, and decoding with UseNumber() carries each number's original literal text through untouched rather than the lossy float64 default. This is deliberately not a full RFC 8785 implementation; the one documented gap is that object keys are ordered by Go's byte-wise UTF-8 comparison rather than UTF-16 code-unit order — the two agree for every key made of Basic-Multilingual-Plane characters (in practice, every real MCP tool argument name) and diverge only outside it. A future revision that needs strict cross-language byte-for-byte reproducibility should adopt a dedicated JCS library instead.
6. Response: decision endpoint → ConstleLink to this section
{
"request_id": "hg_7f3a9c2e",
"decision": "approved",
"subject_digest": "sha256:4b3f...e91a",
"signature": "z3xQb...c7f1",
"decided_at": "2026-09-04T14:22:41Z"
}request_id: must echo the request'srequest_idverbatim. It is what binds the decision to one specific gate. Two gates for the same tool call with the same arguments are indistinguishable bysubject_digestalone, so a decision that does not echo the id is answering a different gate and is denied (§7 step 4), however genuine its signature.decision:"approved"or"denied". Any other value, or a missing field, is treated as denied.subject_digest: must echo the digest from the request verbatim. The approver isn't signing "I approve request hg_7f3a9c2e" (a label Constle could relabel later); they're signing the literal hash of the tool-call bytes. What-you-see-is-what-you-sign.signature: Ed25519 signature over the exact bytesrequest_id + "." + decision + "." + subject_digest(UTF-8, ASCII period separators), signed with the private key matching the Agentfile'sapprover_pubkey.
7. Verification (Constle side)Link to this section
- Decode
approver_pubkeyfrom the Agentfile viapkg/did.Decode(). - Reconstruct the signed payload (
request_id + "." + decision + "." + subject_digest) from the response fields. - Verify
signatureagainst that payload with the decoded public key. - Confirm the response's
request_idmatches the one Constle minted for this gate. - Confirm the response's
subject_digestmatches the one Constle sent in the request. - Only if steps 3, 4 and 5 succeed, and
decision == "approved", does the tool call proceed.
Steps 4 and 5 are separate checks, and neither is implied by step 3. The signed payload covers
request_idandsubject_digestboth, so a valid signature proves only that the approver said this about that request_id and that digest — not that either is the one this call is waiting on. Checking the digest alone leaves the gate open to replay: two invocations of the same tool with the same arguments share asubject_digestby construction (§5 is a pure function of name and arguments), andrequest_idis the only thing distinguishing them. Without step 4, a decision genuinely signed for the first invocation verifies cleanly against the second, and one human "yes" to a repeatable call silently answers every identical gate that follows it. Step 4 is checked against the locally-held id rather than inferred from the decision having been fetched from arequest_id-derived URL (§4.1) — a verification step that fails closed cannot depend on how its input was obtained.
8. Fail-closed behaviorLink to this section
The boundary is narrower than "fail closed, always," and stating it precisely matters more than stating it strongly: a decision that arrives is fail-closed; the absence of a decision is resolved by operator policy. Those are two different kinds of claim — one is a property of this protocol, the other is a configuration default — so they are separated below rather than summed into a single sentence that is only true under the default.
8.1 A decision that arrives — unconditionalLink to this section
| Condition | Result |
|---|---|
approver_pubkey missing from Agentfile |
constle validate fails — agent cannot run at all |
approver_pubkey present but not a valid did:key Ed25519 string |
constle validate fails |
| Signature verification fails | denied, logged as EventGateSignatureInvalid |
request_id mismatch between request and response |
denied, logged as EventGateRequestIDMismatch |
subject_digest mismatch between request and response |
denied, logged as EventGateDigestMismatch |
decision missing, malformed, or anything other than "approved" |
denied |
No configuration relaxes any row above: at an armed gate, nothing turns one of these into an approval. (The one thing that removes them is human_gates.enabled: false, which disarms every gate so that no call is ever held and no decision is ever solicited — the rows do not apply because there is no gate, not because they were softened. constle validate still requires a valid approver_pubkey in that state, and Constle warns at both validate and run time that the declared entries will run without approval.) Verification returns approved on exactly one path — signature, request_id and subject_digest all check out and decision == "approved" — and no code path treats an unverifiable or malformed decision as approval. A decision that fails any check denies the call at once and stops polling: it is never retried into a later poll that might answer differently.
8.2 No decision at all — policy, not guaranteeLink to this section
When the deadline passes and no channel has produced a decision, there is nothing to verify and §8.1 does not apply. human_gates.on_timeout decides, and both of its values are legal:
on_timeout |
Result |
|---|---|
abort (the default) |
The call is refused and the run is terminated. Logged as gate_timeout. |
proceed |
The call is forwarded without approval. Logged as gate_timeout. |
Everything that yields no decision lands here, including cases that look like failures rather than like silence:
- The decision endpoint is unreachable, or is reachable and never answers.
- The endpoint answers
200with a body that does not parse as a decision object (§4.1) — indistinguishable from "not yet". approver_pubkeyis declared but nohuman_gates.notifyURL resolves, so no decision endpoint exists to poll. Constle warns at run time and continues with the terminal prompt as the only decision channel; it does not refuse to start.
Under on_timeout: abort each of these blocks the call. Under on_timeout: proceed each of them forwards it. A broken or misconfigured webhook therefore fails toward whatever on_timeout names — toward blocking the agent on the default, toward letting the call through where an operator has chosen proceed. An agent running proceed has a gate that delays a consequential call rather than one that can block it, and that is a property of the deployment, not a defect in this channel.
8.3 What this channel does not coverLink to this section
A terminal prompt racing this endpoint (§4.1) can approve a gated call on its own. That approval is an unsigned local operator action: it is not signed, not bound to subject_digest, and leaves no artifact anyone can re-verify offline. §8.1 constrains what this channel can be talked into approving. It does not constrain every route by which a gated call can be approved, and an audit log showing an approved gate does not by itself imply a signed decision was involved — decided_by on the gate_approved event is what distinguishes them.
Naming note:
internal/audit/logger.gonames its existing gate eventsEventGateTriggered,EventGateApproved,EventGateDenied,EventGateTimeout(string values"gate_triggered","gate_approved","gate_denied","gate_timeout") — aGateprefix, notHumanGate. The three new constants above follow that existing convention (EventGateSignatureInvalid/"gate_signature_invalid",EventGateRequestIDMismatch/"gate_request_id_mismatch",EventGateDigestMismatch/"gate_digest_mismatch") rather than introducing a newHumanGateprefix alongside it.
9. Audit logLink to this section
Every terminal event of a gate decided through this channel — gate_approved, gate_denied, and the three fail-closed events of §8.1 — carries the decision that produced it, alongside the identifying fields of the request it answers. A denial is the exception in both directions: two of them are reachable before a request exists at all — when the subject digest or the request body cannot be built — so a gate_denied may carry nothing, and verification does not require it to. It requires evidence only of gate_approved, because a denial needs no approver signature to justify having blocked a call. What has to be provable is a call that ran. A gate that timed out unanswered records the request alone, so it can still be correlated with the receiver's own records by request_id. Anyone holding the Agentfile's approver_pubkey can re-verify, offline, that a given decision was genuinely signed by the approver's key over that subject_digest — which is a weaker statement than "over that exact tool call", and deliberately so: the fourth limitation in §10 is that nothing in the log ties the digest back to the tool name the entry displays; constle audit verify --approver-pubkey=<did:key:…> <logfile> does exactly that.
"The full response object" means its fields, not its bytes. The four signed fields and the one unsigned one are recorded as fields, not as the raw HTTP body they arrived in. The two are equivalent here, and only here: §6's signature covers the derived string request_id + "." + decision + "." + subject_digest, which a verifier reconstructs from the parsed fields and never from the body, so a record of the fields reproduces the signed payload exactly. The codebase's other two signed payloads sign their own wire bytes and genuinely do require them (see the note in §5); this one does not. Keeping the body instead would add nothing verifiable while copying an unbounded, endpoint-controlled blob into a signed, hash-chained log that is designed to travel.
Each of the four endpoint-supplied fields is bounded at 256 bytes — far above any real value, since a request_id, a subject_digest and an Ed25519 signature all have fixed short lengths. The bound covers exactly what an untrusted endpoint chooses, because the records that matter most are the rejected decisions, whose contents it chose entirely. It does not cover the recorded request's agent_name and tool name, or approver_pubkey: those are operator configuration, validated at parse time, and are recorded as the manifest declares them. Across a run the evidence grows linearly with the number of gates — one record per gate, no per-run budget — as every other audit event does. A record that hit the bound is marked as truncated, and the verifier reports it as unverifiable rather than as a bad signature: those bytes were dropped by the runtime, not forged by the endpoint.
decided_at is recorded but not attested. It falls outside the signed payload, so it is the endpoint's own claim about when it decided, and nothing verifies it.
The recorded request omits tool_call.arguments. It carries request_id, agent_name, the tool name, subject_digest and the timestamp. Arguments routinely carry secrets and payloads, and the signature attests to the digest rather than to them, so excluding them costs nothing that the signature itself establishes. It is not free: it is exactly what leaves the digest unrelatable to the displayed tool name, which is the fourth limitation in §10. The trade is deliberate — a travelling log that cannot be made to leak an argument, against a verifier that cannot by itself say which call a signed digest was for. The digest is not a substitute for the secrecy of the arguments, and the record should not be read as though it were: subject_digest is an unsalted SHA-256 over the tool name and arguments, so over an enumerable argument space — a PIN, a boolean confirmation, an address from a known set — it is recoverable by brute force. §5 requires exactly that construction, so it cannot be salted without breaking the cross-side reproducibility it exists for. Treat it as a forensic handle rather than a confidentiality boundary, and assume anyone who can read the log can learn guessable arguments. (internal/mcpgate's runGate states the same caveat at the point the digest is computed.)
The approver key in use is recorded too. The paragraph above assumes a verifier who brings approver_pubkey from the Agentfile; recording it as well pins which key the runtime actually verified against, so a runtime that ran with a swapped approver key leaves a log that visibly disagrees with the Agentfile rather than one that verifies cleanly against the swap. Pinning the key at verification time is what acts on that difference — an unpinned check accepts whatever key the log names, which establishes the log's internal consistency and nothing about its trust anchor.
What this closes. Verification compares the cryptography against the event the log claims, in both directions: a gate_approved that does not re-verify as approved is an approval the declared approver never gave, and a gate_signature_invalid that now verifies cleanly is a denial the log misattributes to the approver. An approval that was never signed can therefore no longer be recorded as though it had been, and an approval with no decision recorded at all fails however many other entries share that shape — an absence of proof is not excused by the absence being thorough.
What it does not close is three things. A host that lies about what it is asking approval for lies before any of this is written (§10). A genuine decision can still be relabelled afterwards as answering a different call, because excluding the arguments leaves nothing to recompute the digest from (§10). And decided_by is narrower than it may look: every entry carrying a decision is verified whatever that field says, and the field decides only one question — whether an approval carrying NO decision is exempt. The single exempt value is terminal, because §8.3 states that a terminal approval signs nothing and leaves no artifact to re-verify. An approval with no decision and any other value, or none, fails: a provenance claim a forger can decline to make is not one worth reading.
10. Known limitationsLink to this section
- No rotation or revocation mechanism. A compromised approver key stays valid until someone notices and edits the file.
- The webhook keypair is not part of
internal/identity. A future version may unify it with the agent DID system if a real need for cross-referencing emerges. - Ed25519 removes host-side forgery, not host-side coercion. The runtime host can still lie about what it's asking approval for before the digest is computed. This spec closes the "declared approval that was never real" gap; it doesn't make the host itself trustworthy by assumption.
- A recorded decision does not bind to the tool call the log names it for. The signature covers
subject_digest, and the digest commits to the tool name and the arguments together (§5) — but §9 excludes the arguments, so nothing reading the log offline can recompute it. Whoever writes the log can therefore present a genuine approval for one call as an approval for another, by rewriting the recorded tool name and the entry's own together. A verifier catches the half-done version of this — it requires the entry's own tool name and digest and the recorded request's to be present and equal, so deleting either side is itself a failure — and it cannot catch the consistent one. Recording the arguments is not the only way out of this, and §9's exclusion of them is not what makes it permanent: a future revision could have the approver sign the tool name as a field of its own, or sign a commitment to the name and a separate argument digest, either of which would bind the label without publishing the arguments. What is fixed is that the CURRENT signed fields cannot do it. Therequest_idrecorded alongside is what settles such a case out of band: the approver's endpoint holds its own record of what it was shown for that id, and §4.1 makesrequest_idthe idempotency key it files that under. - Single approver per agent. Multi-approver / M-of-N gating is not supported by this version.
- The signed-decision guarantee is not the gate's whole guarantee. §8.1 holds unconditionally, but it covers only decisions that arrive through this channel. A gate can still be resolved without one: by
on_timeoutwhen nothing answers (§8.2), or by an unsigned terminal approval that wins the race (§8.3). Read §8 in full before treating "the approver's key" as the only thing standing in front of a gated call.