MAQPNADocs

Human approvals

Hold risky tool calls for a person, decide them from the CLI, MAQPNA Desk or the console, and set up four-eyes approval, approver groups and user approval (CIBA).

flowchart LR
  A[Agent calls a tool] --> B{Rule action}
  B -- require_approval --> C[Gateway holds the call<br/>approval pending]
  C --> D[Approver reviews it<br/>CLI, MAQPNA Desk or console]
  D -- approve --> E[That one call runs]
  D -- deny with a note --> F[Agent gets -32001<br/>reason approval_denied]
  C -- no decision in time --> G[Approval expires<br/>call denied]
  E --> H[Audit ledger]
  F --> H
  G --> H

Goal#

Make a person decide before an agent runs a destructive or high-impact tool call, and record who decided. You will hold a call, deny it with a reason from the CLI, approve one from MAQPNA Desk, and learn how four-eyes approval, approver groups and user approval work in production.

An approval is a tool call that a rule held for a human decision. It is pending, then approved, denied or expired. Approving lets exactly that one call run, once.

Prerequisites#

  • A local MAQPNA: maqpna dev up (see Your first governed agent). Its built-in policy has the rule destructive-needs-human, which holds delete_*, drop_*, destroy_*, terminate_*, scale_*, merge_* and deploy_*.
  • Two terminals.
  • In a cluster: the approver role for the namespace (see Who may approve).

Steps#

1. Write an approval rule#

A rule with action: require_approval holds matching calls. The reason is shown to the approver and the agent:

  rules:
  - name: destructive-needs-human
    tools: ["delete_*", "drop_*", "destroy_*", "terminate_*", "scale_*", "merge_*", "deploy_*"]
    action: require_approval
    reason: destructive or high-impact operation
    maxCallsPerMinute: 10

Test it before you load it (see Write and test policies):

maqpna policy test --policy policy.yaml --ns dev --agent coder --server echo --tool delete_resource \
  --expect require_approval

2. Make a call that is held#

In the first terminal, call delete_resource. The call waits for a decision:

maqpna dev run -- maqpna call --server echo --tool delete_resource --arg id=db-1 --arg namespace=dev
maqpna dev: session dev-f19c3be3 (agent coder, namespace dev, on behalf of you@localhost)

The local gateway expires a pending approval after 5 minutes (approvalTimeoutSeconds: 300 in .maqpna/gateway.json). maqpna call waits a little longer (--timeout, default 5m30s).

3. Review it#

In the second terminal, load the local admin token and list pending approvals:

eval "$(maqpna dev env)"
maqpna approvals list
ID                           STATUS    NAMESPACE      AGENT          TOOL                     REASON
apr_77c929b95a8a6cf4b6cb63f8 pending   dev            coder          echo/delete_resource     destructive or high-impact operation

maqpna approvals get ID shows everything an approver needs: the session, the user the agent acts for, the policy rule, an argument preview, the hashes of the arguments and of the tool definition, and the expiry:

ID:                 apr_902e3a670541349ffc7844a3
Status:             pending
Namespace:          dev
Agent:              coder
Session:            dev-9d49283c
User:               you@localhost
Tool:               delete_resource
Server:             echo
Rule:               destructive-needs-human
Reason:             destructive or high-impact operation
Args preview:       {"id":"db-1","namespace":"dev"}
Args SHA-256:       eb7b969d77013fd421c56dcfa2e86f0b95ebe1930d1c96063b0ceb562bfddcd8
Tool def SHA-256:   sha256:69e35d14f2c8c5a052a79fd54f0df65647f780f52817727d69cfc226c5acbf9e
Created:            2026-10-02T23:41:29.022664-04:00
Expires:            2026-10-02T23:46:29.022664-04:00
Required approvals: 1
You can decide:     true

To wait for new approvals in a script, use maqpna approvals watch --exit-on-first.

4. Deny it with a reason#

maqpna approvals deny apr_77c929b95a8a6cf4b6cb63f8 --approver reviewer@localhost \
  --note "db-1 is shared; open a change ticket"
apr_77c929b95a8a6cf4b6cb63f8: denied (by reviewer@localhost)

The held call in the first terminal returns at once, with exit status 3:

error -32001: denied by policy default-agent-policy rule destructive-needs-human: approval_denied by reviewer@localhost (apr_77c929b95a8a6cf4b6cb63f8)
data: {"detail":"approval_denied by reviewer@localhost (apr_77c929b95a8a6cf4b6cb63f8)","domain":"maqpna.com","policy":"default-agent-policy","reason":"approval_denied","rule":"destructive-needs-human"}
Hold a destructive call for approval and deny it with a reason.cast

To approve instead:

maqpna approvals approve ID --approver reviewer@localhost --note "test resource"

With a static admin token, --approver names the approver (default: your OS user name). With an OIDC token, the gateway takes the approver from the token and ignores --approver.

5. Decide from MAQPNA Desk#

MAQPNA Desk is the desktop app for approvers. maqpna desk serves the same page on 127.0.0.1 and opens it in your browser; the native apps for macOS, Windows and Linux wrap it in a window with a tray icon and notifications.

maqpna desk

With a local MAQPNA running, Desk uses its gateway and admin token. Otherwise it uses --gateway, $MAQPNA_GATEWAY_URL or the current context, and without any of them it port-forwards to the gateway Service of your kube context.

For each pending approval, the inbox shows what the agent wants to do (agent, tool, namespace, user, session, argument preview), which policy rule held it, a risk summary and the last ten steps of the session.

MAQPNA Desk inbox with a pending approval

MAQPNA Desk inbox in the light theme

Approving takes two clicks: Approve…, then Approve. Denying needs a reason:

Denying an approval in MAQPNA Desk with a reason

MAQPNA Desk: deny a held EUR 250,000 transfer with a reason, then follow the session timeline

Desk adds no credentials and no privileges. It calls the same admin API as maqpna approvals, so every approver check below applies. Before a vote it re-reads the approval and refuses the vote if the approval is no longer pending or its arguments changed.

6. Decide from the console#

The console has the same inbox for teams that work in a browser:

maqpna console --open                                  # in a cluster: port-forwards to the gateway
maqpna console --gateway http://127.0.0.1:8080 --open  # a local MAQPNA

Sign in with an access token from your identity provider. In development or a break-glass case, the gateway's static admin token also works (maqpna dev env prints the local one).

The console approvals page

The console approve dialog with approver and note

The session timeline shows the decision and the note:

A denied approval in the console session timeline

7. Use async mode in an agent#

By default a held call blocks until a person decides. An agent that should keep working sends the header X-Maqpna-Async: 1. The gateway then answers at once with JSON-RPC -32002 and the approval ID:

maqpna dev run --agent ops --session ops-1 -- maqpna call --server echo --tool delete_resource \
  --arg id=db-1 --arg namespace=dev --header X-Maqpna-Async:1
error -32002: approval required (destructive or high-impact operation); retry with header X-Maqpna-Approval-Id once approved
data: {"approvalId":"apr_c40146f4e24621f834df7cdb","detail":"destructive or high-impact operation","domain":"maqpna.com","expiresAt":"2026-10-02T23:59:30.373302-04:00","policy":"default-agent-policy","reason":"approval_required","rule":"destructive-needs-human"}

After an approver approves it, retry the same call with X-Maqpna-Approval-Id:

maqpna dev run --agent ops --session ops-1 -- maqpna call --server echo --tool delete_resource \
  --arg id=db-1 --arg namespace=dev --header X-Maqpna-Approval-Id:apr_c40146f4e24621f834df7cdb
      "text": "deleted resource db-1 in namespace dev (simulated)",

An approval is used once. A second retry with the same ID is denied with approval_invalid: approval: already used. The SDKs do this for you: async mode raises ApprovalPending with the approval ID (see the Python SDK and framework adapters).

Who may approve#

The gateway's admin API authenticates every decision. Configure it with the Helm values adminAuth:

adminAuth.mode Approver identity
token The static admin token. The approver name is whatever the request says (--approver). For development and air-gapped bootstrap only.
oidc An access token from your identity provider. The approver is taken from the verified token (usernameClaim, default preferred_username, then email, then sub).
both OIDC, plus the static token as a break-glass credential. Every use is logged.

Roles map identity-provider groups to permissions. approver decides approvals; viewer, auditor, admin and killswitch are the others:

adminAuth:
  mode: oidc
  oidc:
    issuer: https://idp.example.eu/realms/maqpna
    audience: maqpna-admin
    clientID: maqpna-console
  roles:
    approver: ["maqpna-approvers"]
    admin: ["maqpna-admins"]
    killswitch: ["maqpna-sre"]
  namespaceRoles:
    team-a: {approver: [team-a-leads]}

Separation of duties is always enforced. The user the session acts for, and the agent itself, can never approve its calls: the gateway answers HTTP 409 self_approval.

Four-eyes approval and approver groups#

Set approval.minApprovers on a rule to require several distinct approvers, and approverGroups to require each of them to be in one of those groups:

  - name: payment-instructions-four-eyes
    tools: [submit_payment_instruction]
    action: require_approval
    reason: payment instructions need two approvers
    approval: {minApprovers: 2, approverGroups: [treasury-supervisors]}

The first approval leaves the request pending:

apr_b907112dfc44199703f0fe98: pending (1 of 2 approvals; by <nil>)

approvals get then shows Required approvals: 2 and Approved by: alice@bank.example. One deny is final. You can also set four-eyes outside the policy with the Helm value approvals.quorum, as a default or per "<policy>/<rule>".

User approval (CIBA)#

Some calls should be confirmed by the person the agent acts for, on their own device, rather than by an approver. Set userConfirmation: ciba on the rule:

    action: require_approval
    approval: {userConfirmation: ciba}

The gateway then asks your identity provider to confirm with the user (OpenID CIBA). Only that user's own confirmation can approve; an approver's approve is refused. Configure it with the Helm values delegation.ciba (enabled, issuer, clientID, clientSecretFile, userClaim, scopes, acrValues, requestedExpirySeconds). fallback: approvers (the default) lets approvers decide when CIBA is unavailable; fallback: deny denies the call instead. Add the identity provider to sovereignty.allowedEgressHosts.

Notifications#

The gateway can notify a webhook, Slack, Microsoft Teams or Matrix when a call is held. Set the Helm values approvals.notifiers, approvals.notifierSecret and approvals.consoleURL (the link in the notification). approvals.preview.mode sets what approvers see of the arguments: none, hash, redacted (the default; obvious secrets masked) or full.

Verify#

maqpna approvals list --status all          # every approval and its final state
maqpna dev timeline --last                  # the approval and the decision on the call
TIME      KIND       SERVER/TOOL           DECISION          DETAIL
00:00:52  approval   echo/delete_resource  require_approval  apr_77c929b95a8a6cf4b6cb63f8 denied
00:01:09  tool_call  echo/delete_resource  deny              approval_denied by reviewer@localhost (apr_77c929b95a8a6cf4b6cb63f8)

In a cluster, maqpna audit tail --decision deny shows denied calls with the approver.

Troubleshooting#

Symptom Cause Fix
HTTP 409: self_approval You are the user the session acts for, or the agent Ask another approver
HTTP 409: duplicate_approver You already approved, or a second vote used the same static token Use a second person's OIDC credential
HTTP 403 on approvals list or a decision Your token has no approver or admin role for the namespace Run maqpna whoami; ask an admin to map your group in adminAuth.roles or namespaceRoles
The agent gets approval_expired or the call times out Nobody decided before the approval expired Decide sooner, use async mode, or raise the gateway's approvalTimeoutSeconds
approval_invalid: approval: already used An approval ID was retried twice Request a new approval
The approval is pending after one approve The rule needs minApprovers: 2 A second approver decides
Desk or the console labels a denied call approved_by_human A known labelling issue in the gateway timeline: a record with an approver is labelled human-approved even when the approver denied it Read the decision column (deny) and maqpna approvals get ID, which are correct
maqpna approvals list -n team-a or approvals watch -n team-a fails with unknown flag -n The approvals commands have no namespace filter Filter the JSON: maqpna approvals list -o json --jq '.approvals[] \| select(.namespace=="team-a") \| .id'

Next steps#