Skip to main content

Recipe: First approval flow end-to-end

escalate_on_deny does not raise an approval request

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. 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

Availability

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: true is accepted by the policy schema, but nothing reads it yet -- it does NOT make a rule auto-escalate (#2391). Call client.request_approval() explicitly to raise a request.
  • The SDK requires --email at 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