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
curl -O https://docs.controlzero.ai/recipes/dev-vs-prod-enforcement/scenarios.json
# 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.
controlzero test-policy policy.yaml --scenarios scenarios.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 an approval workflow, wait for the approvals recipe (Phase 4) or wire your own approval service on top of the warn event.- 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.