Set up approvals (human-in-the-loop)
Status: BETA
Available in: Teams (Free + Solo can read but cannot enable; approvals need a separate approver)
SDK: Python 1.6.0+ (current published line 1.9.6 on PyPI), Node @controlzero/sdk (current published line 1.8.3 on npm; the Node approval API ships in 1.11.x, ahead of npm)
Approvals turn a policy deny into a request a human can approve, instead of a hard stop that forces developers to soften the rule. When the engine denies an approval-eligible action, the SDK pauses, posts an approval request, and waits for a teammate to decide. This guide is the end-to-end how-to: turn the toggle on, find the inbox, request approval from your code, and handle the "approvals disabled" path.
Approvals are available on every deployment, including the hosted (SaaS) plan. The feature is in Beta: shipped and usable, with control-plane hardening still in flight. Approvals are off by default -- an administrator turns them on per scope (org, project, or API key). The dashboard and API steps below apply to any deployment.
1. Turn approvals on (or off)
Approvals are off by default. Until an administrator opts in, every
request_approval() call returns E1500
and the SDK honors the original deny. This fail-closed default is intentional:
an unconfigured organization cannot silently accept approval traffic that no
human will review.
From the dashboard
- Open Settings -> Approvals.
- Pick the scope you want to govern: the whole organization, a single project, or a specific API key.
- Flip the toggle on, choose an approver, and save.
The toggle is stored per scope and resolved with a cascade. See
Approval settings and cascade for the full precedence
rules (api_key -> project -> org -> fail-closed default).
From the API
The dashboard toggle calls the same endpoint your automation can call. Point
$CONTROLZERO_API_HOST at your deployment's API (the hosted SaaS API or your
own deployment):
# Read the current setting for an org
curl -s $CONTROLZERO_API_HOST/api/orgs/$ORG_ID/hitl-settings \
-H "Authorization: Bearer $CONTROLZERO_API_KEY"
# Turn approvals on for the org (admin or owner role required)
curl -s -X PUT $CONTROLZERO_API_HOST/api/orgs/$ORG_ID/hitl-settings \
-H "Authorization: Bearer $CONTROLZERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scope": "org", "enabled": true}'
To govern a single project instead of the whole org, send
{"scope": "project", "scope_id": "<project-id>", "enabled": true}. The most
specific scope wins.
2. Tag a rule as approval-eligible
Only rules you mark are eligible. Add escalate_on_deny: true to the deny
rules you want to convert into approval requests:
version: '1'
rules:
- deny: 'Bash:sudo *'
escalate_on_deny: true
reason: 'sudo requires admin approval'
- allow: 'Bash:*'
A deny without escalate_on_deny stays a hard deny. This keeps the blast
radius of approvals scoped to exactly the actions you choose.
3. Request approval from your code
When guard() returns a denied decision whose hitl_eligible flag is true,
call request_approval() and wait for the human decision.
Python
from controlzero import Client, PolicyDeniedError
from controlzero.errors import ApprovalsDisabled
client = Client() # reads ~/.controlzero/config.yaml (api_key + identity.email)
decision = client.guard("Bash:sudo apt-get install python3-foo")
if decision.denied and decision.hitl_eligible:
try:
request = client.request_approval(
decision,
message="installing test dep for FOO-1234",
timeout_s=300,
)
except ApprovalsDisabled as exc:
raise SystemExit(f"Approvals are off at scope: {exc.resolved_scope}")
final = request.wait() # blocks; polls /api/approval-requests/{id}
if final.denied:
raise PolicyDeniedError(final)
# proceed; final.approved_via_hitl is True
Node
import { Client, PolicyDeniedError, ApprovalsDisabled } from '@controlzero/sdk';
const client = new Client();
const decision = await client.guard('Bash:sudo apt-get install python3-foo');
if (decision.denied && decision.hitlEligible) {
try {
const request = await client.requestApproval(decision, {
message: 'installing test dep for FOO-1234',
timeoutS: 300,
});
const final = await request.wait();
if (final.denied) {
throw new PolicyDeniedError(final);
}
// proceed; final.approvedViaHitl is true
} catch (err) {
if (err instanceof ApprovalsDisabled) {
console.error(`Approvals are off at scope: ${err.resolvedScope}`);
} else {
throw err;
}
}
}
The full request_approval / wait API reference -- observable attributes,
mock mode for local dev, polling cadence, and the exception hierarchy -- lives
on the Approval callback page.
Identity is required
Approvals need to know which human triggered the request, so the SDK requires an email at install time:
controlzero install --api-key cz_live_xxx --email alice@acme.com
The email rides on every backend call as the X-CZ-Requestor-Email header.
Without it the SDK raises E1707. For
shared keys (CI, project keys) this is what attributes a request to a person --
see Multi-user keys.
4. The approvals inbox
Pending requests land in two places for the approver:
- In-app inbox. The dashboard lists open requests at the approvals inbox,
backed by
GET /api/approval-requests. Each row shows the requestor, the action, the message, and the scope that governed it. - Notification. An in-app bell badge plus an email (with a magic link for cold sessions). Configure delivery channels under Notification channels.
The approver opens a request and picks a decision:
| Decision | Effect |
|---|---|
| Deny | The original deny stands; the SDK raises a deny error |
| Approve once | Single call only; auto-revokes after first use or 5 min |
| Approve timed | 24h, 7d, 30d, or custom (cap 90d, max 365d) |
| Approve forever | A standing grant, revocable from the grants admin page |
Programmatically, an approver decides via
POST /api/approval-requests/{id}/decide with {"action": "approve"} or
{"action": "deny"}. Full decision-kind semantics are on the
Approval Workflow concept page.
5. What happens when approvals are disabled
If the cascade resolves to off at the requesting scope, the request is rejected with HTTP 412 and no approval request is created:
{
"error": "approvals_disabled_at_scope",
"resolved_scope": "project",
"reason_code": "E1500",
"documentation": "https://docs.controlzero.ai/errors/E1500-approvals-disabled"
}
The SDK surfaces this as a typed ApprovalsDisabled exception (code
E1500) carrying the resolved_scope, so
your code can tell the operator exactly which toggle to flip. If no settings
row exists at any scope, you get E1704
instead -- create the row rather than toggling an existing one.
Always wrap request_approval() in a handler for ApprovalsDisabled: an
agent must not crash just because an admin turned approvals off.
6. Verify the audit lineage
Every approval is auditable. Open the audit view and filter by Decision source = Approval. Each approved call records the requestor, the approver, the timestamps, the grant id, and the decision kind, so a reviewer can reconstruct who approved what and when.
See also
- Approve risky actions. The use-case framing
- Recipe: First approval flow end-to-end. A 10-minute walkthrough
- Recipe: Approvals on a multi-dev project. Shared-key identity
- Approval Workflow. Concept + decision kinds
- Approval settings and cascade. The toggle precedence
- SDK: Approval callback. Full request/wait API reference
- Secrets approvals. Approvals on
get_secret()