Human gates
Name a tool, and its calls that pass through the gate pause until a human decides. Two channels can answer (the terminal prompt and a signed webhook decision) and the first decision wins.
On this page
Tip
Step by step
Require human approval at the MCP gate sets up a gate from the approver key to the recorded decision.
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; required whenever you list gated tools
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 URLenabledmust be written wheneverrequire_approval_forlists entries: an Agentfile that lists gated tools and never saysenabled:is refused, so a gate can't be silently off.enabled: falsedisarms every gate, andvalidateandrunwarn about the disarmed entries.require_approval_forentries are exact, case-sensitive MCP tool names, each one or more ASCII letters, digits,_,-or.: theparams.nameof atools/callon a server declared undermcp.servers. Calls to a named tool that pass through the gate wait for a decision. 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 hold MCP tool calls that pass through the gate, and nothing else. Traffic to hosts you allow isn't gated. If an allowed host shares a server with an MCP server, a request can reach that MCP server past the gate (limitation 2). There is no gate on filesystem writes either: they have no chokepoint outside the sandbox.
What happens at run timeLink to this section
Calls to a gated tool that pass through the gate are paused: the gate 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
The prompt, abridged (the subject digest is shortened):
⏸ human gate: agent "invoice-processor" wants to call MCP tool "pay_invoice" on server "accounting"
subject: sha256:4b3f…e91a
arguments (25 bytes, 3 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 instead of prompting 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.
A line typed at the terminal before a prompt appears, or a late answer to an earlier prompt, can be read as the answer to this one (limitation 9). Answer a prompt only after reading it.
The signed webhook decisionLink to this section
With approver_pubkey set and a notify URL resolving, Constle also POSTs each paused call that passed through the gate 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, only if
decision == "approved", and only if anydecided_atreads as an RFC 3339 timestamp, forward the call.
Fail closed, and where it doesn'tLink to this section
A decision that arrives is verified, and any flaw in it denies the call. 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 |
"approved" with a decided_at that is not an RFC 3339 timestamp |
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.
Caution
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. The decision's decided_at is unsigned and is not recorded as a decision time: its time in the log is the entry's own timestamp, from the gate's clock. constle audit verify --agentfile=<path> re-verifies each recorded decision against the Agentfile's approver_pubkey, so a webhook approval the approver never signed fails verification. A terminal approval (decided_by: terminal) carries no signature, so there is nothing to re-verify. See Audit log and verification.