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 |