Skip to main content

Human-in-the-Loop approval workflow

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.

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)

Availability

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

  1. Author a strict policy. Write the deny rules that should be reviewable.
  2. 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.
  3. Approver gets notified. In-app bell + email (with a magic-link for cold sessions).
  4. Approver picks one: Deny / Approve once / Approve for 24h, 7d, 30d / Approve forever.
  5. 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 kindWhat it doesStorage
approved_onceSingle call only; auto-revoke after first use OR 5 minuteshitl_grants row, args_hash-bound
approved_timed24h, 7d, 30d, or custom (max 90d)hitl_grants row, expires_at-bound
approved_forever_grantForever; revocable from /grants admin pagehitl_grants row, expires_at IS NULL
approved_foreverPolicy edit: inserts allow rule above the denyPolicy 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 /approvals page 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