Approvals on secret reads
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: Free Solo Teams (free for all tiers) Status: BETA SDK: 1.6.0+
Secret approvals share the approval (HITL) feature. The request path works on every deployment, including the hosted (SaaS) plan. The feature is in BETA and is off by default -- an administrator turns it on per scope.
The approver-facing pages are not reachable yet. The approvals inbox and request
detail routes redirect to the dashboard in every shipped deployment, so an
approver cannot release a held secret from the UI today; the request runs to its
deadline and the SDK raises HITLTimeoutError with a synthesized deny. The
secret is not returned, so the read fails closed. See
Set up approvals for the toggle and the end-to-end flow.
Secrets are higher-stakes than tool calls. A leaked OPENAI_API_KEY costs more than a single bad rm. Approvals on secret reads give admins a checkpoint: when an agent fetches a credential, the admin can approve, deny, or scope the grant to a single user.
Canonical actions
| Action | Default policy | What it does |
|---|---|---|
Secrets:read <name> | Allow if explicitly granted | Agent fetches a credential value |
Secrets:list | Allow if explicitly granted | Agent enumerates secret names (no values) |
Secrets:write <name> | Hard-deny in starter policy | Admin operation; usually not for agents |
Secrets:delete <name> | Hard-deny in starter policy | Admin operation; usually not for agents |
Typical project policy:
version: '1'
rules:
- allow: Secrets:list
- deny: Secrets:read
escalate_on_deny: true
reason: 'credential reads need approval'
- deny: Secrets:write
- deny: Secrets:delete
The escalate_on_deny: true tag records your INTENT that this deny is one a
human should review. It does not raise the request -- see the warning above.
Your code raises it, by calling client.request_approval(decision) on the
returned deny.
SDK API
# Python (1.6.0+)
secret = client.get_secret("OPENAI_API_KEY")
secret = await client.get_secret_async("OPENAI_API_KEY")
Internally get_secret(name):
- SDK calls
client.guard("Secrets", method="read", ...), which is what matches theSecrets:readrule above. (guard()has noresourceparameter, and the colon formguard("Secrets:read")does not match that rule -- it falls through to the fail-closed default.) - If allow, fetch from secrets backend; return value.
- If deny, and your code treats this rule as one a human should review, call
client.request_approval(decision). On approve, fetch and return. There is no flag on the decision that marks it approval-eligible for you --decision.requires_approvalstaysfalse. - If deny (final), raise
PolicyDeniedError.
Hybrid gate (defense in depth)
Two layers gate every secret read:
- SDK gate (fast path):
get_secret()consults the policy engine before touching the secrets backend. - Secrets backend gate (backstop): the secrets backend independently consults the policy engine on every read, catching non-SDK callers (curl, MCP server, browser ext).
A malicious SDK that bypasses its own gate still fails the backend check.
Value redaction (three layers)
The secret value NEVER appears in:
- Audit logs: only
secret_name+secret_value_sha256(64-char hex). DB CHECK constraint at the table layer rejects any row that violates this. - Telemetry: PostHog events for
Secrets:*actions are dropped entirely; no payload field passes a value-shape regex. - Errors + stack traces: explicit redaction in every exception path.
- Approver UI: drawer shows
value: <hidden, sha256: ab12...cd34>. Never the value itself.
If any layer attempts to log a value-shaped string for a Secrets:* action, the SDK raises E1709 SecretValueLeakInPayload and the request fails closed.
Approval drawer for secrets
Same drawer pattern as tool-call approvals, with three differences:
- Red-bordered "SECRET ACCESS" banner at the top.
- Secret name visible; value sha256 fingerprint visible; value never visible.
- "Last N reads of this secret" inline list for anomaly detection.
The approver verifies they're approving the right secret by matching the sha256 against the expected value, without ever seeing the value itself.
Default scope: user-scoped (opposite of tool-call default)
Tool-call Approve forever defaults to project-wide. Secret Approve forever defaults to user-scoped (condition.user IN [requestor_email]).
Reasoning: a long-lived secret rule for the whole project undermines the vault. Admin must explicitly remove the condition.user clause via "Edit before applying" to widen.
Rotation invalidates grants
When the secret value is rotated, a trigger on secrets.updated_at automatically sets revoked_at = NOW() on all hitl_grants rows for that secret_name. Reasoning: a grant was issued against a specific value; rotation invalidates it; next fetch needs a new approval.
When NOT to require approval on a secret
- Public config (e.g.
PUBLIC_ANALYTICS_KEY). Approval friction with no benefit. - Build-time-only credentials read once at boot. Approval would block boot.
- Mass-fetch helpers that pull dozens of secrets in a loop. Per-fetch approval would be unworkable.
Tag the secrets you'd lose sleep over if they leaked. Leave routine config un-tagged.
See also
- Approval Workflow. The parent concept
- Multi-user keys. Identity for shared API keys
- E1709 secret value leak
- E1710 secret approval required
- E1711 secret not found