SDK: Approval callback
Supported modes: Hosted Hybrid Local
Status: BETA
SDK: Python 1.6.0+ (current published line 1.9.6 on PyPI) and Node @controlzero/sdk (current published line 1.12.x, served from the Control Zero registry at https://npm.controlzero.ai). The Node example below uses requestApproval / wait, which ship in the current published 1.12.x 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.
The request_approval() / wait() round-trip is available on every deployment, including the hosted (SaaS) plan. The feature is in Beta. Approvals are off by default; until an administrator turns the per-scope toggle on, request_approval() returns E1500 and the SDK honors the original deny. See Set up approvals for the toggle and end-to-end flow.
When client.guard() returns a denied decision and decision.hitl_eligible is true, you can request approval interactively via client.request_approval(). This page is the API reference.
Python
from controlzero import Client, PolicyDeniedError
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:
request = client.request_approval(
decision,
message="installing test dep for FOO-1234",
timeout_s=300, # default 300s; server cap 1800s
)
final = request.wait() # blocks; polls /api/approval-requests/{id}
if final.denied:
raise PolicyDeniedError(final)
# proceed; final.approved_via_hitl is True
For async code:
final = await request.wait_async() # event-loop friendly
Observable attributes
request is a PendingApproval with readable attributes:
request.poll_interval_s. Current poll interval (1s for first 10 polls, then exp backoff to 10s ceiling)request.deadline_at. Absolute timestamp whenwait()raisesHITLTimeoutErrorrequest.status."pending" | "resolved" | "expired"
Mock mode (local dev)
client = Client(api_key="...", hitl_mock="approve_after_2s")
# Modes: "approve_after_2s" | "approve_timed_after_2s" |
# "approve_forever_after_2s" | "deny_after_2s" |
# "timeout" | None (live)
In-process; no backend stand-up. Same request_approval / wait flow; the mock short-circuits the network calls.
CLI helper
controlzero test Bash:sudo --hitl approve
controlzero test Bash:sudo --hitl deny
controlzero test Bash:sudo --hitl timeout
Walks the SDK through the approval branch end-to-end with the mock.
Node
import { Client, PolicyDeniedError } from '@controlzero/sdk';
const client = new Client();
const decision = await client.guard('Bash:sudo apt-get install python3-foo');
if (decision.denied && decision.hitlEligible) {
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
}
Node has no sync HTTP, so there's no wait_async distinction; wait() returns a Promise. Same observable attributes (pollIntervalS, deadlineAt, status).
Exception class hierarchy
All SDKs share the same E_CODE catalog. Catching PolicyDeniedError catches the timeout + identity-rejected + no-approver classes too:
| Class | Parent | E_CODE |
|---|---|---|
HITLTimeoutError | PolicyDeniedError | E1701 |
HITLBackendUnreachableError | HostedBootstrapError | E1702 |
HITLPolicyVersionConflictError | HybridModeError | E1703 |
HITLNotConfiguredError | RuntimeError | E1704 |
HITLNoApproverAvailable | PolicyDeniedError | E1705 |
HITLIdentityNotInOrg | RuntimeError | E1706 |
HITLIdentityRequired | RuntimeError | E1707 |
HITLIdentityClaimRejected | PolicyDeniedError | E1708 |
The class names retain the HITL prefix because they are part of the SDK's stable public API surface. Renaming them would be a breaking change for any caller that catches them by name.
Idempotency
request_approval() auto-generates an Idempotency-Key UUID per invocation. Agent retries with the same key return the same request_id, so no duplicate row on the backend. The key is opaque to the user; you don't construct or read it.
Polling cadence (public contract)
wait() polls at 1s intervals for the first 10 polls, then exponential backoff up to a 10s ceiling. This is stable across the 1.x line. SDK 1.5.7 polls the same way; SDK 1.6.0 polls the same way; future minors won't change it without a major version bump.
If you need different cadence, do not call wait(). Use the lower-level client.poll_approval(request_id) instead.
See also
- Approval Workflow
- Multi-user keys. Identity setup
- Secrets approvals. Approvals on
get_secret()