Skip to main content

SDK: Approval callback

Supported modes: Hosted Hybrid Local Status: BETA SDK: Python 1.6.0+ (current published line 1.13.x on PyPI) and Node @controlzero/sdk (current published line 1.13.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 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.

Availability

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.requires_approval 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.requires_approval:
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.effect == "allow"

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 when wait() raises HITLTimeoutError
  • request.status. "pending" | "resolved" | "expired"

Mock mode (local dev)

There is no Client constructor kwarg for mock approvals. Drive the in-process mock approval backend through the shipped CLI helper below -- it walks the SDK's approval branch end-to-end with no backend to stand up.

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.requiresApproval) {
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.effect === 'allow'
}

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:

ClassParentE_CODE
HITLTimeoutErrorPolicyDeniedErrorE1701
HITLBackendUnreachableErrorHostedBootstrapErrorE1702
HITLPolicyVersionConflictErrorHybridModeErrorE1703
HITLNotConfiguredErrorRuntimeErrorE1704
HITLNoApproverAvailablePolicyDeniedErrorE1705
HITLIdentityNotInOrgRuntimeErrorE1706
HITLIdentityRequiredRuntimeErrorE1707
HITLIdentityClaimRejectedPolicyDeniedErrorE1708

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.

wait() (and its async twin wait_async()) is the only supported way to poll for an approval outcome; the cadence above is fixed and there is no public lower-level single-poll primitive.

See also