Skip to main content

Set up approvals (human-in-the-loop)

escalate_on_deny does not raise an approval request in any released SDK

The deny fires. A rule tagged escalate_on_deny: true denies exactly as written -- the matched rule's own policy_id, effect: "deny", reason_code: "RULE_MATCH". You are protected.

The escalation does not, in the SDKs you can install today (Python 1.13.14 and earlier, Node 1.13.6 and earlier). The tag is accepted by the policy schema and carried into the policy bundle, and no enforcer acts on it: no approval request is raised, no approver is notified, and decision.requires_approval stays false. Code that branches on decision.requires_approval in order to act on this tag therefore never runs.

This is changing. Support has landed on main and will ship in the next SDK release: a tagged deny will then set requires_approval and route through guard_with_approval(), which drives the approval to a terminal state and fails closed if it cannot reach one. Plain guard() will keep denying either way -- it never prompts, and never asks the restricted actor to approve their own denial. Check your installed version rather than this sentence: the released behaviour is what your code runs against. (A different mechanism, LLM function policies' require_approval, does set that field -- escalate_on_deny is not wired to it.) Tracking: #2391.

To request approval today, call it explicitly. client.request_approval(decision) posts a real approval request. Nothing about the tag calls it for you. See Approval callback. Per #2363 the approver-facing /approvals pages are gated in production, so requests are resolved through the API.

This applies to the published Python SDK (controlzero 1.13.14) and the published Node SDK (@controlzero/sdk 1.13.6) alike.

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.13.x on PyPI), Node @controlzero/sdk (current published line 1.13.x, served from the Control Zero registry at https://npm.controlzero.ai; the Node approval API ships in the current published line)

Configure the Control Zero registry once: add @controlzero:registry=https://npm.controlzero.ai to your .npmrc (or run npm config set @controlzero:registry https://npm.controlzero.ai). It applies to npm install and npx for the whole @controlzero scope.

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 a call and your code asks for approval, the SDK 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.

Availability

The approval request path works on every deployment, including the hosted (SaaS) plan: your code raises the request, the SDK waits, and an administrator turns the flow on per scope (org, project, or API key). The feature is BETA and is off by default.

The approver-facing pages are not reachable yet. The approvals inbox and request detail routes redirect to the dashboard in every shipped deployment, and the notification deep link points at that same path, so an approver cannot resolve a request from the UI today. A request nobody resolves runs to its deadline and the SDK raises HITLTimeoutError with a synthesized deny, so the original deny stands. Enable the flow now if you want the audit trail and the pause; plan for the inbox to land before you depend on approve-and-proceed.

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

  1. Open Settings -> Approvals.
  2. Pick the scope you want to govern: the whole organization, a single project, or a specific API key.
  3. 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. Mark the rules you intend to review

Tag the deny rules a human should review with escalate_on_deny: true. Today this tag is documentation of intent for whoever reads the policy -- it is accepted and stored, and in the released SDKs it does not itself convert anything into a request (see the warning at the top of this page). Step 3 is what raises the request today:

version: '1'
rules:
- id: require-sudo-approval
deny: 'Bash:sudo *'
escalate_on_deny: true
reason: 'sudo requires admin approval'
- allow: 'Bash:*'

In the released SDKs a deny stays a hard deny whether or not it carries the tag. Tagging exactly the rules you intend to review is still worth doing now: the wiring has landed on main (#2391 is closed), so when it ships the blast radius is already scoped to the actions you chose -- and until then the tag is an accurate record of that intent for anyone reading the policy.

3. Request approval from your code

When guard() returns a denied decision that your code treats as reviewable, call request_approval() and wait for the human decision. Branch on decision.denied plus your own condition: PolicyDecision carries no hitl_eligible attribute, and decision.requires_approval is not set by escalate_on_deny.

Python

from controlzero import Client, PolicyDeniedError
from controlzero.errors import ApprovalsDisabled

client = Client() # reads ~/.controlzero/config.yaml (api_key + identity.email)

# The rules you decided a human should review. Keep this list in step with
# the rules you tagged `escalate_on_deny: true` in step 2.
REVIEWABLE_RULE_IDS = {"require-sudo-approval"}

decision = client.guard("Bash:sudo apt-get install python3-foo")
# `PolicyDecision` has no `hitl_eligible` attribute and `escalate_on_deny`
# does not set `requires_approval`, so branch on the deny plus your own
# condition -- here, the rule id you chose to make reviewable.
if decision.denied and decision.policy_id in REVIEWABLE_RULE_IDS:
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.status == "approved"

Node

import { Client, PolicyDeniedError, ApprovalsDisabled } from '@controlzero/sdk';

const client = new Client();

// The rules you decided a human should review. Keep this set in step with
// the rules you tagged `escalate_on_deny: true` in step 2.
const REVIEWABLE_RULE_IDS = new Set(['require-sudo-approval']);

const decision = await client.guard('Bash:sudo apt-get install python3-foo');
// `PolicyDecision` has no `hitlEligible` field and `escalate_on_deny` does
// not set `requiresApproval`, so branch on the deny plus your own condition.
if (decision.denied && REVIEWABLE_RULE_IDS.has(decision.policyId)) {
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.status === 'approved'
} 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:

DecisionEffect
DenyThe original deny stands; the SDK raises a deny error
Approve onceSingle call only; auto-revokes after first use or 5 min
Approve timed24h, 7d, 30d, or custom (cap 90d, max 365d)
Approve foreverA 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