Approval¶
When a decision is ask, the Guard asks its approval provider. Without a provider, or
when the provider rejects, times out, fails or returns something malformed, the call is
denied. Approval can never override a deny.
AgentGuard ships two providers:
| Provider | Use it for |
|---|---|
QueueApproval |
Real deployments: approvers decide in the dashboard, Slack or your own system. Does not block the agent. Supports time-boxed grants. |
CLIApproval |
Local development: a prompt in the terminal running the agent. |
Queued approval¶
from agentguard import Guard
from agentguard.approval import ApprovalStore, QueueApproval
store = ApprovalStore("agentguard-approvals.db")
guard = Guard("policy.yaml", approval=QueueApproval(store))
agentguard approvers add alice # prints alice's token once
agentguard approvers add sam --strong # may give strong approval
agentguard dashboard # http://127.0.0.1:8765
The agent is not blocked¶
With the default wait=0, an ask call raises ApprovalPending immediately with a
request ID. The agent can continue with other work. When a human approves, retrying
the same call goes through:
from agentguard import ApprovalPending
try:
deploy(env="staging")
except ApprovalPending as pending:
print("Waiting for", pending.request_id) # e.g. apr_3f...
# ...later, after approval:
deploy(env="staging") # runs
Framework adapters return this to the model as
{"error": "Approval pending", "request_id": ..., "next_step": "...retry the same call later"}.
- Retrying before a decision reuses the same request; it does not create a new one.
- A recently rejected call is denied immediately instead of asking again.
QueueApproval(store, wait=30)waits up to 30 seconds for a decision inside the call before raisingApprovalPending. Keep the Guard'sapproval_timeoutabovewait.approval.wait_for(request_id, timeout)andawait approval.await_decision(...)let your code wait for a decision explicitly.- Requests expire after
request_ttl(default one hour).
Grants: "allow this for 10 minutes"¶
Approving creates a grant. The approver picks its scope:
| Scope | Covers | Duration |
|---|---|---|
once |
This exact call, one time | Must be retried within 15 minutes |
action |
This exact call (tool and arguments), any number of times | 1 min to 24 h |
tool |
Any call to this tool | 1 min to 24 h |
capability |
Any tool with this capability | 1 min to 24 h |
Grants apply to the same agent_id and environment as the request. They are checked
before asking again, never cover deny decisions, and cover strong-approval decisions only
if given with strong approval. Revoke one in the dashboard or with store.revoke_grant.
Approvers and strong approval¶
Approvers authenticate with a token created by agentguard approvers add. Only its SHA-256
hash is stored. An approver created with --strong can approve requests whose risk crosses
the policy's strong threshold, and must re-enter their token for each such decision.
Notifications¶
from agentguard.approval import QueueApproval, SlackNotifier, WebhookNotifier
approval = QueueApproval(
store,
notifiers=[
SlackNotifier(bot_token=..., channel="#agent-approvals",
dashboard_url="https://guard.internal.example"),
WebhookNotifier("https://ops.example/hooks/agentguard", secret=...,
dashboard_url="https://guard.internal.example"),
],
)
Slack. With a bot token (chat:write), messages get Approve once, Allow this tool
10 min and Reject buttons. Enable Interactivity in the Slack app with the request URL
<dashboard>/slack/actions, run the dashboard with --slack-signing-secret-env VAR, and
link each approver to their Slack user: agentguard approvers add alice --slack-user U0123.
Every click is verified with Slack's signing secret. Requests that need strong approval get
a dashboard link instead of buttons. With only an incoming webhook_url, messages link to
the dashboard.
Webhook. Sends {"type": "approval.requested", "request": {...}} with
X-AgentGuard-Timestamp and X-AgentGuard-Signature: sha256=<hmac> over
"<timestamp>.<body>". Verify it with agentguard.approval.webhook.verify_signature, then
decide through the dashboard API:
curl -X POST https://guard.internal.example/api/approvals/apr_3f.../decision \
-H "Authorization: Bearer $APPROVER_TOKEN" -H "Content-Type: application/json" \
-d '{"approve": true, "scope": "tool", "minutes": 10, "note": "release window"}'
Notification failures are logged and never block the agent. Arguments in notifications are redacted.
Terminal approval¶
from agentguard.approval import CLIApproval
guard = Guard("policy.yaml", approval=CLIApproval(timeout=30), approval_timeout=31)
The operator sees the redacted action, risk score and reasons, and types A to approve
once or R to reject. It needs an interactive terminal, blocks the agent while it waits,
and does not perform strong approval: decisions that require it are denied.
Custom providers¶
Any object with a request(action, decision) method returning an Approval works:
from agentguard import Approval
class MyApproval:
def request(self, action, decision):
answer = ask_somebody(action, decision) # your code
return Approval(
approved=answer.approved,
approved_by=answer.user_id, # required when approving
strong=answer.reauthenticated, # only after real reauthentication
)
Rules the Guard enforces on every answer:
approvedmust be a realbool; anything else is a rejection.- An approval without
approved_by(or with"unknown") is a rejection. - If the decision needs strong approval (
decision.strong_approval),strong=Trueis required. Approval(False, pending_id="...")means "queued"; the call raisesApprovalPending.- The provider receives a deep copy of the action; editing it cannot change what runs.
- Answers after
approval_timeoutare ignored.
Providers are trusted code. AgentGuard checks the shape of the answer, not who gave it.