Get release news

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

agent.yamlyaml
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 URL
  • enabled must be written whenever require_approval_for lists entries: an Agentfile that lists gated tools and never says enabled: is refused, so a gate can't be silently off. enabled: false disarms every gate, and validate and run warn about the disarmed entries.
  • require_approval_for entries are exact, case-sensitive MCP tool names, each one or more ASCII letters, digits, _, - or .: the params.name of a tools/call on a server declared under mcp.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 no mcp.servers declared, nothing is gated. This is limitation 1.
  • approver_pubkey is required whenever require_approval_for is non-empty. It is a did:key for an approver keypair made with constle webhook-keygen, which is not an agent identity: it authenticates the human deciding, not the agent asking.
  • notify names 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):

Output
⏸  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:

  1. Decode approver_pubkey from the Agentfile.
  2. Rebuild the signed payload from the response fields.
  3. Verify the signature with that key.
  4. Confirm request_id is the one minted for this gate (so one "yes" can't answer an identical later call).
  5. Confirm subject_digest is the one sent (the hash of the exact tool call).
  6. Only then, only if decision == "approved", and only if any decided_at reads 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_digest is 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.