Human-in-the-Loop approval workflow
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.
Supported modes: Hosted Hybrid Local Available in: Teams (Free + Solo can read but cannot enable; needs a separate approver) Status: BETA SDK: 1.6.0+ (validator-additive in 1.5.8)
The approval request path works on every deployment, including the hosted (SaaS) plan: policy raises the request, the SDK pauses, and an administrator turns the flow on per scope (org, project, or API key) under Settings -> Approvals. The feature is in 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. See Set up approvals for the end-to-end guide.
When the policy engine denies a tool call, the user's only options today are: read the deny message, edit the policy, redeploy. Friction is high. Customers soften their policies to avoid the friction, and governance erodes. Approvals turn the deny moment into a request moment, when a per-scope toggle is on and your code asks for one.
How it works
- Author a strict policy. Write the
denyrules that should be reviewable. - Agent hits the rule. The call is denied. Your code decides that this deny
is one a human should see, and calls
client.request_approval(decision), which POSTs an approval request to the backend. This step is explicit: no policy tag performs it for you. - Approver gets notified. In-app bell + email (with a magic-link for cold sessions).
- Approver picks one: Deny / Approve once / Approve for 24h, 7d, 30d / Approve forever.
- SDK resumes. Agent proceeds with the call (allow path), or honors the deny (deny path).
Every approval is auditable: who approved, when, why, what grant was created or what policy diff was applied.
Decision kinds
The approver picks one of:
| Decision kind | What it does | Storage |
|---|---|---|
approved_once | Single call only; auto-revoke after first use OR 5 minutes | hitl_grants row, args_hash-bound |
approved_timed | 24h, 7d, 30d, or custom (max 90d) | hitl_grants row, expires_at-bound |
approved_forever_grant | Forever; revocable from /grants admin page | hitl_grants row, expires_at IS NULL |
approved_forever | Policy edit: inserts allow rule above the deny | Policy version bump; rule carries created_by_hitl metadata |
The default is approved_once. Admins ratchet up scope when they see a pattern.
"Who can use this approval?" picker
For every grant decision, the approver picks the principal scope:
- Just <requestor_email> (default for tools + timed grants)
- Anyone using project X
- Anyone on machine Y
- Anyone using key Z
- Custom condition (admin edits the rule manually)
For secrets, the default is user-scoped even on approved_forever. A long-lived secret grant for the whole project undermines the vault. See Secrets approvals.
Identity layer
The approval flow needs to know which human triggered the request. API keys are machine credentials; they can be shared. The SDK requires controlzero install --email <email> at install time; the email is sent on every backend call as the X-CZ-Requestor-Email header.
This matters most for shared API keys (project keys, CI keys). See Multi-user keys for the threat model and forensic story.
What approvals are NOT
- Not a replacement for good policy authoring. Approvals are the escape hatch when a deny would otherwise force a customer to soften the rule.
- Not silent. Every approval is in the audit log. The
/approvalspage is the canonical surface for security review. - Not unlimited. Custom grant duration is capped at 365 days (default cap 90 days, configurable per scope).
- Not for Free / Solo tiers. Both tiers have a single user; self-approval theater gives no governance. Upgrade to Teams to use it.
See also
- Secrets approvals. Approvals on credential reads
- Multi-user keys. Identity for shared API keys
- SDK:
request_approval+wait. The SDK API - Approval settings and cascade. The per-scope on/off toggle
- E1701 approval timeout
- E1707 identity required
- E1500 approvals disabled at scope