Recipe: Deny-list (block the dangerous few)
The problem
You want the agent to move freely. Writing an allow-list for every tool it might legitimately call is too much work and breaks the moment the agent reaches for a tool you did not anticipate. What you actually care about is a short, bright-line list of operations that are never acceptable: deleting files, dropping tables, wiping disks, writing over SSH keys. Everything else is fine.
That is a deny-list posture, and it is the inverse of Control Zero's
default. By default a policy with rules is an allow-list: any call
that no rule matches is denied (default_action: deny). Set
default_action: allow and the no-match path flips -- now an unmatched
call is allowed, and your deny: rules are the only thing that blocks
anything.
The policy
version: '1'
# Deny-list posture.
#
# settings.default_action: allow -> a call that matches NO rule below
# is allowed (and audited). The deny
# rules are the entire block-list.
#
# This is the inverse of the default allow-list (deny-by-default) posture.
# Reach for it when you want the agent to work freely but need hard,
# never-cross blocks on a small set of dangerous operations.
#
# Note the other two knobs stay `deny`: flipping the no-match posture to
# allow does NOT mean "allow everything, always". A missing or tampered
# bundle still fails CLOSED -- an outage or an altered policy can never
# silently turn the deny-list off.
settings:
default_action: allow
default_on_missing: deny
default_on_tamper: deny
rules:
- id: deny-rm
deny: 'Bash:rm'
reason: 'Destructive file deletion is never allowed.'
- id: deny-dd
deny: 'Bash:dd'
reason: 'Raw disk writes (dd) are never allowed.'
- id: deny-db-drop
deny: 'database:DROP'
reason: 'Dropping tables is never allowed.'
- id: deny-db-truncate
deny: 'database:TRUNCATE'
reason: 'Truncating tables is never allowed.'
- id: deny-write-ssh-keys
deny: 'file_write:write'
when:
path: '*/.ssh/*'
reason: 'Writing to SSH key paths is never allowed.'
Attach this policy to the project (or drop it in your local
controlzero.yaml). In the dashboard, the same flip is the project-level
default_action: allow setting on Project Settings.
Why it works
Evaluation order is unchanged from any other policy: an explicit deny
rule always wins, and a rule's when: condition must match for the rule
to apply. The only thing default_action: allow changes is the fall
-through: a call that no rule matches resolves to allow with
reason_code: NO_RULE_MATCH instead of the default deny.
So rm -rf extracts to Bash:rm and hits the deny-rm rule
(RULE_MATCH deny). git commit extracts to Bash:git, matches no
rule, and falls through to the allow default (NO_RULE_MATCH allow).
The deny-write-ssh-keys rule only fires when the write path matches
*/.ssh/*; a write anywhere else matches no rule and is allowed.
What gets blocked
| Agent call | Extracted action | Decision | reason_code |
|---|---|---|---|
rm -rf build | 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 |
write /home/agent/.ssh/authorized_keys | file_write:write | deny | RULE_MATCH |
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 |
write /workspace/src/app.py | file_write:write | allow | NO_RULE_MATCH |
Every allowed call is still written to the audit trail with
reason_code: NO_RULE_MATCH, so a deny-list posture is observable, not
silent.
Test it yourself
curl -O https://docs.controlzero.ai/recipes/deny-list-dangerous-tools/policy.yaml
curl -O https://docs.controlzero.ai/recipes/deny-list-dangerous-tools/scenarios.json
controlzero test-policy policy.yaml --scenarios scenarios.json
Read Enforcement Behavior for the
full description of default_action and the other no-happy-path knobs,
and how org -> project -> user-YAML override precedence resolves them.
Caveats
- A deny-list is only as good as the names on it.
default_action: allowpermits anything you did not think to block. New tools, renamed binaries, and creative arg shapes get through. An allow-list (the defaultdenyposture) is the safer choice when you can enumerate the small set of operations the agent legitimately needs -- see Read-only database. - Keep
default_on_missinganddefault_on_tamperatdeny. They govern the bundle-can't-load and bundle-tampered paths, which are independent of the no-match posture. Leaving them atdenymeans the deny-list cannot be disabled by an outage or by editing the policy file -- the system fails closed even though normal operation is allow-by-default. - Shell and SQL blocks are extractor-based, not a sandbox. A blocked
Bash:rmcovers thermthat the hook extractor surfaces from the command. Pair this recipe with OS-level controls for defense in depth. - For path-scoped reads and writes (allow your repo and
/tmp, deny/etc,~/.ssh,~/.aws), use the allow-list-shaped Scoped file access recipe instead.
Related recipes
- Read-only database -- the allow-list inverse of this recipe.
- Dev warns, prod denies -- the
warnposture for soft rollouts. - Scoped file access -- per-path allow/deny for file tools.
- Block outbound network -- deny curl / wget / ssh / nc.