Skip to main content

Approvals on secret reads

Supported modes: Hosted Hybrid Available in: Free Solo Teams (free for all tiers) SDK: 1.6.0+

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 makes the deny approval-eligible.

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:read", resource=name).
  2. If allow, fetch from secrets backend; return value.
  3. If deny and approval-eligible, call client.request_approval(...). On approve, fetch and return.
  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 E1309 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