Skip to main content

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 callExtracted actionDecisionreason_code
rm -rf buildBash:rmdenyRULE_MATCH
dd if=/dev/zero of=/dev/sdaBash:dddenyRULE_MATCH
DROP TABLE usersdatabase:DROPdenyRULE_MATCH
TRUNCATE usersdatabase:TRUNCATEdenyRULE_MATCH
write /home/agent/.ssh/authorized_keysfile_write:writedenyRULE_MATCH

What gets allowed (and audited)

Agent callExtracted actionDecisionreason_code
git commit -m "wip"Bash:gitallowNO_RULE_MATCH
ls -laBash:lsallowNO_RULE_MATCH
SELECT * FROM usersdatabase:SELECTallowNO_RULE_MATCH
write /workspace/src/app.pyfile_write:writeallowNO_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: allow permits anything you did not think to block. New tools, renamed binaries, and creative arg shapes get through. An allow-list (the default deny posture) is the safer choice when you can enumerate the small set of operations the agent legitimately needs -- see Read-only database.
  • Keep default_on_missing and default_on_tamper at deny. They govern the bundle-can't-load and bundle-tampered paths, which are independent of the no-match posture. Leaving them at deny means 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:rm covers the rm that 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.