Skip to main content

Approvals on secret reads

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: Free Solo Teams (free for all tiers) Status: BETA SDK: 1.6.0+

Availability

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

ActionDefault policyWhat it does
Secrets:read <name>Allow if explicitly grantedAgent fetches a credential value
Secrets:listAllow if explicitly grantedAgent enumerates secret names (no values)
Secrets:write <name>Hard-deny in starter policyAdmin operation; usually not for agents
Secrets:delete <name>Hard-deny in starter policyAdmin 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):

  1. SDK calls client.guard("Secrets", method="read", ...), which is what matches the Secrets:read rule above. (guard() has no resource parameter, and the colon form guard("Secrets:read") does not match that rule -- it falls through to the fail-closed default.)
  2. If allow, fetch from secrets backend; return value.
  3. 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_approval stays false.
  4. If deny (final), raise PolicyDeniedError.

Hybrid gate (defense in depth)

Two layers gate every secret read:

  1. SDK gate (fast path): get_secret() consults the policy engine before touching the secrets backend.
  2. 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