Recipe: First approval flow end-to-end
escalate_on_deny does not raise an approval requestThe 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. 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. (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.
Time: ~10 minutes Prereqs: Teams tier; one teammate with approver permissions; Python SDK 1.6.0+ Status: BETA
The approval request path works on every deployment, including the hosted (SaaS) plan. The feature is in BETA and is off by default -- this walkthrough turns the per-scope toggle on in Step 2.
Read this before you start: 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 the approve-from-the-dashboard step below cannot be completed today. The request runs to its deadline and the SDK raises HITLTimeoutError with a synthesized deny, so the original deny stands. Follow the walkthrough to wire and verify the request path; the resolve step lands with the inbox. See Set up approvals for the full toggle and cascade reference.
This walkthrough sets up a deny on Bash:sudo *, triggers it from an agent, requests approval from your code, approves it from the dashboard, and verifies the audit lineage.
Step 1. Mark the rule you intend to review
Edit your project policy in the dashboard or as YAML:
version: '1'
rules:
- id: require-sudo-approval
deny: 'Bash:sudo *'
escalate_on_deny: true
reason: 'sudo requires admin approval'
- allow: 'Bash:*'
Save the policy.
Step 2. Enable approvals for the project
Go to /settings/hitl. Flip the toggle ON for your project. Pick yourself as the requestor and a teammate as the approver. (Same person equals self-approval theater; the workflow refuses it.)
Click "Send test request" to verify routing. Your teammate should see a bell badge within 60 seconds.
Step 3. Install the SDK with your identity
pip install -U controlzero
controlzero install claude-code --api-key cz_live_xxx --email alice@acme.com
controlzero doctor
The doctor command should print:
IDENTITY: alice@acme.com -- resolved as user_id=<uuid> in org acme OK
Step 4. Trigger the deny + approve flow
from controlzero import Client, PolicyDeniedError
client = Client()
decision = client.guard("Bash", method="sudo apt-get install", args={"command": "sudo apt-get install python3-foo"})
# `escalate_on_deny` does not set `requires_approval`, so your code decides
# which denies are reviewable. Here: the rule id you tagged in step 1.
if decision.denied and decision.policy_id == "require-sudo-approval":
print("requesting approval at https://app.controlzero.ai/my-requests/...")
request = client.request_approval(
decision,
message="installing test dep for FOO-1234",
timeout_s=300,
)
final = request.wait() # blocks
if final.denied:
raise PolicyDeniedError(final)
print("approved, proceeding")
Run it. Your teammate gets a bell + email. They click "Approve once" in the drawer. Within 1 second your terminal prints "approved, proceeding".
Step 5. Verify audit lineage
Open /audit. Filter by "Decision source = Approval". You should see one row:
- Action:
Bash:sudo apt-get install python3-foo - Decision:
allow (via approval) - Approval:
req_abc123(click to open the approval detail)
Expanding the row shows the full lineage: requested by alice@acme.com at T, approved by teammate@acme.com at T+12s, grant_id grnt_x7j2, decision_kind approved_once.
Step 6. Try the deny path
Run the same script again. Teammate clicks "Deny" this time. Your script raises PolicyDeniedError.
What you learned
escalate_on_deny: trueis accepted by the policy schema, but nothing reads it yet -- it does NOT make a rule auto-escalate (#2391). Callclient.request_approval()explicitly to raise a request.- The SDK requires
--emailat install for approval flows. client.request_approval()+request.wait()are the SDK API for the request/approve/resume cycle.- The audit log shows full lineage on every call approved via this flow.
Where to go next
- Recipe: Multi-developer project with shared keys
- Concept: Approval Workflow. Deeper architectural detail
- Concept: Secrets approvals. Approvals on credential reads
- SDK: Approval callback. The full API reference