MAQPNADocs

Approvals, four-eyes and user approval#

An approval is a tool call that a rule held for a human decision, and that decision. When a rule's action is require_approval, the gateway does not forward the call; it creates an approval request and waits for people to decide. Approving lets exactly that one call run, once.

Term Meaning
approval A held call. Status pending, then approved, denied or expired
approver A person with the approver role, globally or for the call's namespace. Admins are not approvers unless they also hold the role
four-eyes approval An approval that needs two (or more) different approvers: minApprovers 2 to 10
user approval An approval by the person the agent acts for, on their own device, through user approval on their own device (CIBA, OpenID Client-Initiated Backchannel Authentication)

The life of an approval#

stateDiagram-v2
    [*] --> pending: rule action require_approval
    pending --> pending: one approval recorded,<br/>quorum not met (approve_partial)
    pending --> approved: quorum met
    pending --> denied: any approver denies<br/>(or the user rejects via CIBA)
    pending --> expired: no decision within<br/>approvalTimeoutSeconds (default 300)
    approved --> [*]: the held call runs once<br/>(consumed flag set)
    denied --> [*]
    expired --> [*]
  • Identifiers. Each request gets an ID apr_<24 hex digits>.
  • Expiry. A request not decided within its TTL (approvalTimeoutSeconds, default 300 seconds) becomes expired, which counts as a denial (approval_expired). Decided requests are kept for one hour.
  • Single use. An approval is bound to the session, server, tool and SHA-256 hash of the arguments. It can be consumed once; a second use is refused (approval_invalid).
  • Durable. With approvalStorePath (file) or stateBackend: postgres, pending requests, votes and the consumed flag survive restarts, so an approval cannot be replayed after a restart. With Postgres, every gateway replica sees every vote and a decision on one replica wakes a caller blocked on another.

Two ways to wait#

Mode How the agent asks What happens
Synchronous (default) A normal tools/call The gateway holds the HTTP request open until the approval is decided or expires, then forwards (or denies) the call
Asynchronous Header X-Maqpna-Async: 1 The gateway answers at once with JSON-RPC error -32002 and {approvalId, expiresAt, policy, rule, reason}. The agent retries the identical call later with X-Maqpna-Approval-Id: <id>. MCP 2026-07-28 clients get an input_required result instead of -32002

Model calls and browser egress cannot wait for approval: a require_approval decision there is a denial (approval_required_unsupported_for_models, approval_required_not_supported_for_egress).

Separation of duties#

The approval store enforces these rules for every channel (API, CLI, console, Desk, Slack and Teams callbacks):

  1. No self-approval. A vote is refused (self_approval) when the approver is the session's user or the agent's SPIFFE ID.
  2. No double votes. A second vote by the same person is refused (duplicate_approver). One static admin token counts as one person, so it can never satisfy four-eyes alone.
  3. Approver groups. When the rule sets approverGroups, the approver must be in one of them (approver_not_in_group).
  4. Any deny is final. One deny ends the request, whatever the quorum.
  5. Verified identity. With OIDC admin authentication, the approver is taken from the verified access token, never from the request body.

Four-eyes quorum#

The quorum comes from two places, merged strictest wins:

  • the matched rule's approval: {minApprovers, approverGroups, userConfirmation} in the ToolPolicy;
  • the gateway's approvalQuorum entry for <policy>/<rule> (or its default).

The larger minApprovers wins. When both restrict groups, the approver must be in one group of each list.

User approval (CIBA)#

A rule with approval.userConfirmation: ciba asks the person the agent acts for to confirm on their own device, through the customer's identity provider (for example Keycloak). The gateway sends a backchannel authentication request with login_hint set to the user and a binding message such as MAQPNA-1A2B3C4D/payments/create_transfer, then polls the identity provider until the user approves or rejects. Only the user can approve; approvers can still deny. If the user cannot be asked, ciba.fallback decides: approvers (default) lets approvers decide, deny refuses the call (user_confirmation_unavailable). See user approval (CIBA).

Where approvers decide#

Surface How
CLI maqpna approvals list, maqpna approvals approve ID, maqpna approvals deny ID --note "reason"
Console Approvals page (refreshes every 5 seconds, shows the last ten timeline steps)
MAQPNA Desk Native notifications and a two-click approve; deny requires a reason
Chat Slack and Microsoft Teams buttons (signed callbacks, mapped users, same role checks); webhooks and Matrix notify only
The user's device CIBA push or prompt from the identity provider

Approvers see an argument preview, never raw arguments by default: approvalPreview.mode is redacted (default; secrets and DLP findings masked), hash, none or full (raw arguments, secrets still masked).

What you see#

maqpna approvals list prints one row per approval (format from cmd/maqpna/approvals.go; values illustrative):

ID                           STATUS    NAMESPACE      AGENT          TOOL                     REASON
apr_3f9c1a2b4d5e6f708192a3b4 pending   team-a         coder          kubernetes/delete_pod    destructive

Deciding prints the new status. While a four-eyes approval still needs votes, it says how many it has:

$ maqpna approvals approve apr_3f9c1a2b4d5e6f708192a3b4 --note "INC-123 cleanup"
apr_3f9c1a2b4d5e6f708192a3b4: pending (1 of 2 approvals; by bob@acme.eu)
$ maqpna approvals approve apr_3f9c1a2b4d5e6f708192a3b4
apr_3f9c1a2b4d5e6f708192a3b4: approved (by carol@acme.eu)

See maqpna approvals list, maqpna approvals approve, maqpna approvals deny and maqpna desk.