Recipe: Dev warns, prod denies
The problem
On a dev laptop the engineer is iterating, and a hard block on every warning kills flow. In production, the same action is a stop-the-world event. You do not want to maintain two sets of rules -- just one rule set with two different enforcement postures.
The policy
version: '1'
# Same policy rules for dev and prod. The enforcement behavior is
# driven entirely by the `settings` block at the org / project level.
#
# - dev project: settings.default_action = warn (log only, nothing
# is actually blocked)
# - prod project: settings.default_action = deny (hard block)
#
# The policy rules below identify "dangerous writes" the same way in
# both environments; only the settings differ.
rules:
- id: dangerous-db-deny
deny: 'database:DROP'
reason: 'Drops are not allowed in any environment.'
- id: dangerous-db-deny-truncate
deny: 'database:TRUNCATE'
reason: 'Truncates are not allowed in any environment.'
- id: dangerous-shell-deny
deny: 'Bash:rm'
reason: 'rm is not allowed in any environment.'
In the dashboard, attach this policy to both your dev project and
your prod project. On the dev project, set the project-level
default_action to warn. On the prod project, set it to deny
(the org-wide default).
Why it works
The signed policy bundle carries the three enforcement knobs
(default_action, default_on_missing, default_on_tamper)
alongside the rule list. When the SDK or Gateway evaluates a call, an
explicit deny rule always wins -- that is why DROP and rm are
blocked everywhere. Anything NOT matched by a rule falls through to
default_action, which is warn in dev and deny in prod. Same rule
file, different posture.
What gets blocked
| Environment | Agent call | Extracted action | Decision | reason_code |
|---|---|---|---|---|
| prod | DROP TABLE users | database:DROP | deny | RULE_MATCH |
| dev | DROP TABLE users | database:DROP | deny | RULE_MATCH |
| prod | rm -rf build | Bash:rm | deny | RULE_MATCH |
| prod | SELECT * FROM users (no rule matches) | database:SELECT | deny | NO_RULE_MATCH |
| prod | curl example.com (no rule matches) | Bash:curl | deny | NO_RULE_MATCH |
What gets allowed (with a warn log)
| Environment | Agent call | Extracted action | Decision | reason_code |
|---|---|---|---|---|
| dev | SELECT * FROM users (no rule matches) | database:SELECT | warn | NO_RULE_MATCH |
| dev | curl example.com (no rule matches) | Bash:curl | warn | NO_RULE_MATCH |
Note: in dev, a hand-written deny rule still fires -- warn only
applies to the "no rule matched" path. This is exactly what you want:
DROP is a bright-line never-do, regardless of environment.
Test it yourself
curl -O https://docs.controlzero.ai/recipes/dev-vs-prod-enforcement/policy.yaml
# scenarios.json is not hosted -- build it from the expected-decision table above.
# The scenarios file uses `settings_override` on each case so one YAML
# can test both dev (warn) and prod (deny) postures in a single run.
# Run each documented posture with the corresponding tool, method, and JSON arguments:
controlzero test <tool> --method <method> --policy policy.yaml --args '<json>'
Read Enforcement Behavior for
the full override precedence (organization -> project -> user YAML) and for the complete list of reason_code values.
Caveats
warnlets the call through and logs. It does NOT add a prompt back to the agent asking for confirmation. For human confirmation, see First approval flow and Approval for destructive actions.- The three-level override (org -> project -> user YAML) is resolved
server-side when the bundle is built. If a user ships their own
controlzero.yamlwithdefault_action: allow, the bundle from the backend still wins when the SDK is in hosted mode. The user YAML only overrides in local-only mode. - A
warnoutcome still counts toward your monthly evaluations on metered plans. Audit-mode rollouts are cheaper on Teams than on Free.