Recipe: Approval (HITL) for destructive actions
Status: BETA
Approvals (human-in-the-loop, "HITL") are available on every deployment,
including the hosted (SaaS) plan. The feature is in Beta, needs the
Teams tier, and is off by default -- you turn it on per scope in
the dashboard. This recipe is the policy shape; the
First approval flow walkthrough wires it end-to-end
(enable the toggle, install the SDK with --email, approve from the
dashboard), and Set up approvals is the toggle
and cascade reference.
The problem
You want the agent to move freely, but a short list of operations must
never run unattended: deleting files, dropping or truncating tables,
mass row deletes, and privilege changes (GRANT / REVOKE). For those
you want a green-light before it runs -- a human approves the specific
call, in context, and only then does it proceed. Everything the operator
does not approve must fail closed.
That is a human-in-the-loop (HITL) approval gate, and Control Zero ships it as a per-rule tag plus a request/approve/resume flow in the SDK.
The policy
version: '1'
# HITL approval for destructive actions.
#
# The agent works freely (settings.default_action: allow), but a short,
# named inventory of destructive operations is tagged for human review:
#
# escalate_on_deny: true -> marks the rule as approval-eligible. When
# the SDK is wired to a backend that has
# approvals turned on, a matching call is
# routed through the grants flow (request ->
# operator approve / deny -> resume) instead
# of a plain deny.
#
# IMPORTANT -- what this fixture proves is the fail-closed BASELINE:
# absent an approval backend (or when it is unreachable), an
# escalate_on_deny rule DENIES. The destructive call never runs on its
# own. The runtime approve -> allow / deny / timeout outcomes depend on
# live operator state and are covered by the SDK's HITL integration
# tests, not by this static input->decision fixture. See the recipe page.
#
# The other two knobs stay `deny`: a missing or tampered bundle still
# fails CLOSED, so the approval gate cannot be disabled by an outage or
# by editing the policy file.
settings:
default_action: allow
default_on_missing: deny
default_on_tamper: deny
rules:
- id: approve-rm
deny: 'Bash:rm'
escalate_on_deny: true
reason: 'File deletion needs an operator green-light.'
- id: approve-dd
deny: 'Bash:dd'
escalate_on_deny: true
reason: 'Raw disk writes (dd) need an operator green-light.'
- id: approve-db-drop
deny: 'database:DROP'
escalate_on_deny: true
reason: 'Dropping a table needs an operator green-light.'
- id: approve-db-truncate
deny: 'database:TRUNCATE'
escalate_on_deny: true
reason: 'Truncating a table needs an operator green-light.'
- id: approve-db-delete
deny: 'database:DELETE'
escalate_on_deny: true
reason: 'Row deletion needs an operator green-light.'
- id: approve-db-grant
deny: 'database:GRANT'
escalate_on_deny: true
reason: 'Granting privileges needs an operator green-light.'
- id: approve-db-revoke
deny: 'database:REVOKE'
escalate_on_deny: true
reason: 'Revoking privileges needs an operator green-light.'
Attach this policy to the project (or drop it in your local
controlzero.yaml), then turn approvals on for the scope in the
dashboard (/settings/hitl).
Why it works
There are two layers, and it helps to keep them separate.
-
escalate_on_deny: trueis a per-rule tag. It marks adenyrule as approval-eligible. The tag is additive: a policy that loads it behaves exactly like a plaindenyrule everywhere the approval backend is not in the loop. -
The approval flow runs in the SDK, against the backend. When the SDK is connected to a backend that has approvals enabled, a call that matches an
escalate_on_denyrule is surfaced as an approval-eligible decision (decision.hitl_eligible). The SDK then posts a request, an operator approves or denies it, and the call resumes -- see the resolution table below.
The important consequence -- and the thing this recipe's fixture proves
-- is the fail-closed baseline: with no approval backend wired in
(local-only mode), or when the backend is unreachable, an
escalate_on_deny rule simply denies. The destructive call never
runs on its own. Approval is the only path from that deny to an allow;
there is no path where a missing approver becomes a silent allow.
So rm -rf extracts to Bash:rm and hits approve-rm
(RULE_MATCH deny, approval-eligible). git commit extracts to
Bash:git, matches no rule, and falls through to the allow default
(NO_RULE_MATCH). SELECT is not in the destructive inventory, so it
is allowed and audited; DROP, TRUNCATE, DELETE, GRANT, and
REVOKE each hit their approval-gated rule.
What is approval-gated (and denies without a granted approval)
| Agent call | Extracted action | Decision | reason_code |
|---|---|---|---|
rm -rf /var/data | Bash:rm | deny | RULE_MATCH |
dd if=/dev/zero of=/dev/sda | Bash:dd | deny | RULE_MATCH |
DROP TABLE users | database:DROP | deny | RULE_MATCH |
TRUNCATE users | database:TRUNCATE | deny | RULE_MATCH |
DELETE FROM orders WHERE id = 42 | database:DELETE | deny | RULE_MATCH |
GRANT ALL ON orders TO analyst | database:GRANT | deny | RULE_MATCH |
REVOKE SELECT ON orders FROM ... | database:REVOKE | deny | RULE_MATCH |
The RULE_MATCH deny is what you observe without a granted
approval. Wired to an approvals-enabled backend, the same match is
routed through the request/approve/resume flow instead.
What gets allowed (and audited)
| Agent call | Extracted action | Decision | reason_code |
|---|---|---|---|
git commit -m "wip" | Bash:git | allow | NO_RULE_MATCH |
ls -la | Bash:ls | allow | NO_RULE_MATCH |
SELECT * FROM users | database:SELECT | allow | NO_RULE_MATCH |
What the operator and agent see
When approvals are enabled and a call matches an escalate_on_deny
rule, the agent-side SDK drives the request/approve/resume cycle. The
low-level API (see First approval flow for the full
walkthrough):
from controlzero import Client, PolicyDeniedError
client = Client()
decision = client.guard("Bash:rm", args={"command": "rm -rf /var/data"})
if decision.denied and decision.hitl_eligible:
request = client.request_approval(decision, message="cleanup for TICKET-1")
final = request.wait() # blocks until the operator decides
if final.denied:
raise PolicyDeniedError(final) # denied / timed out -> stop
# approved -> proceed
The operator approves or denies from the dashboard approval queue, and every resolved request is written to the audit trail with full lineage (requestor, approver, timestamps, grant id).
The request resolves to exactly one terminal outcome. Each carries a
distinct machine-readable reason_code so the audit trail shows why
a call did or did not proceed:
| Outcome | Decision | reason_code |
|---|---|---|
| Operator approved | allow | HITL_GRANT_APPROVED |
| Operator denied | deny | HITL_GRANT_DENIED |
| Request expired (server SLA) / caller deadline | deny | HITL_GRANT_EXPIRED / HITL_GRANT_TIMEOUT |
| Grant revoked after approval | deny | HITL_GRANT_REVOKED |
| Caller cancelled the wait | deny | HITL_GRANT_CANCELED |
| No API key / backend unreachable | deny | HITL_BACKEND_UNREACHABLE |
| Approver not in the org's approver pool | deny | HITL_IDENTITY_NOT_IN_ORG |
| No approver identity configured on the requestor | deny | HITL_IDENTITY_REQUIRED |
| Approved args do not match the resumed call | deny | HITL_ARGS_HASH_MISMATCH |
Every non-approval outcome is a deny. A timeout, a cancellation, a backend outage, or an unauthorized approver can never be mistaken for an allow -- the flow fails closed on every path except an explicit, in-pool operator approval.
What this recipe's fixture proves -- and what it cannot
The CI fixture at
tests/fixtures/enforcement-spec/recipes/hitl-approval-destructive/
runs each scenario through the SDK policy evaluator as a static
input -> decision check. That harness can prove:
- Each destructive rule is approval-gated and denies (
RULE_MATCH) when no approval has been granted -- the fail-closed baseline above. - Non-destructive calls fall through to the
allowdefault (NO_RULE_MATCH). - A tampered bundle still fails closed on an approval-gated action
(
BUNDLE_TAMPERED), so the gate cannot be edited away.
It cannot drive the live approve/deny/timeout outcomes: those depend
on a running backend and a real operator decision, which a static
fixture has no way to simulate. Those paths -- approved -> allow,
denied -> deny, pending -> timeout -> deny, and the
approver-pool / identity gates -> deny -- are covered by the SDK's HITL
integration tests: test_hitl_phase2b_protocol.py,
test_hitl_6a_request_approval.py, test_hitl_6a_wait.py,
test_hitl_6a_get_secret_hitl.py, and test_hitl_reason_codes.py in
sdks/python/controlzero/tests/.
Test it yourself
The recipe fixtures are run by the CI recipe driver. From the repo root:
python tests/fixtures/enforcement-spec/recipes/test_recipes.py
Look for [PASS] hitl-approval-destructive in the output. The driver
loads this recipe's policy.yaml, replays every scenario in
scenarios.json, and asserts the decision, reason_code, and extracted
method match -- so this page cannot drift from what the SDK actually
does.
Caveats
- A gate is only as good as the names on it. With
default_action: allow, anything you did not tag is allowed. New tools, renamed binaries, and creative arg shapes get through. Enumerate the destructive inventory deliberately, and prefer the allow-list (default_action: deny) posture when you can name the small set of operations the agent legitimately needs -- see Read-only database. - Approvals must be enabled for the scope. The
escalate_on_denytag alone does not turn on the request/approve flow; you still flip the per-scope toggle in the dashboard. Until then, a matching call is a plain deny. - Without a backend, this is a deny-list, not an approval flow. In
local-only mode there is no approver to route to, so an
escalate_on_denyrule denies. That is the intended fail-closed behavior, not a bug -- but it means the interactive approval only exists when the SDK is connected to an approvals-enabled backend. - Shell and SQL gates are extractor-based, not a sandbox. A gated
Bash:rmcovers thermthat the hook extractor surfaces from the command. Pair this recipe with OS-level controls for defense in depth.
Related recipes
- First approval flow -- the end-to-end walkthrough: enable the toggle, install with
--email, approve from the dashboard, verify audit lineage. - Approvals on a multi-developer project -- approver pools and shared keys across a team.
- Deny-list: block the dangerous few -- the same destructive inventory, hard-blocked with no approval path.
- Read-only database -- the allow-list inverse: SELECT-only, no destructive SQL at all.
Read the Approval Workflow concept for the
architecture, and Enforcement Behavior
for how default_action and the fail-closed knobs resolve.