Enforcement Behavior
Supported modes: Hosted Hybrid Local Available in: Free Solo Teams
Every Control Zero surface (SDKs, Gateway, coding-agent hooks, browser extension) evaluates your policies the same way, using the same knobs and the same machine-readable decision codes. This page documents the contract so a policy you author once behaves identically everywhere.
Rules match on action strings shaped as tool:method. For coding-agent hooks, the CLI derives the method from the tool's arguments — see Hook action extraction for how it turns a SQL query into database:SELECT or a compound shell command into Bash:rm.
A hosted policy with no rules runs in observe mode — every call is allowed and logged, never blocked. But a hosted policy that DOES have rules, when none of them match a given call, denies it (a fail-closed allow-list). Meanwhile the local claude-code starter template is allow-by-default because it ships a catch-all allow: '*' rule. So the same agent can be observe-only locally and fail-closed under a hosted policy. The Why is my tool denied? how-to is the two-minute fix.
Observe mode and the default_on_empty knob ship together with the policy-posture refinement (#1247 / #1252). They take effect once both your SDK and the bundle-building backend understand default_on_empty; see Compatibility for how older clients behave. The default_action / default_on_missing / default_on_tamper knobs and the fail-closed allow-list described below are already live on current SDKs.
The four knobs
A policy bundle carries four top-level settings that govern what happens outside the happy path of a rule match. All four ship inside the signed, encrypted bundle, so a tampered file cannot flip an organization from deny to allow.
| Knob | Values | Fires when | Default |
|---|---|---|---|
default_on_empty | observe | deny | allow | warn | Bundle loaded successfully but it has zero rules (no policy is attached yet). | observe |
default_action | deny | allow | warn | Bundle loaded with one or more rules, but none of them matched the call. | deny |
default_on_missing | deny | allow | Client is enrolled but no bundle can be loaded (never synced, network error, bad key). | deny |
default_on_tamper | warn | deny | deny-all | quarantine | Bundle verification fails, or machine is in local quarantine. | warn |
default_on_empty answers "there is no policy at all yet" — the brand-new-project case. default_action answers "there is a policy, but it doesn't mention this tool" — the allow-list case. They have opposite safe defaults on purpose: an empty project should not brick an agent on day one (so the default is observe), but a project that has authored an allow-list should fail closed on anything outside it (so the default is deny).
When to reach for each
default_on_empty is the knob that answers, "I just created a project and haven't attached a policy yet. What does my agent do?"
- Keep
observe(the default) so a fresh project is monitoring, not enforcing: every tool call is allowed and written to the audit trail, and the agent loudly tells you it is in observe mode. This is the intended starting posture — watch first, then enforce. - Set
denyif an empty project should fail closed (e.g. a regulated org where "no policy" must mean "no tool calls"). - Set
alloworwarnfor an empty-project posture that allows quietly (allow) or surfaces a soft warning (warn) without the explicit observe-mode banner.
default_action is the knob that answers the question, "I attached a policy that blocks deletes. What happens to writes?"
- Keep
deny(the default) when you want the policy you authored to be the complete list of allowed operations -- a true allow-list. - Set
allowfor a deny-list posture: policies that only block a specific operation class (e.g. "block deletes; everything else is fine"). This is the fix for the classic "I attached db-read-only, why is my AI blocked from everything?" scenario. The Deny-list recipe is a copy-paste example that allows the agent to work freely but hard-blocksrm,DROP, disk wipes, and writes to SSH-key paths. - Set
warnduring a soft rollout to see what the blocks would be before flipping todeny.
default_on_missing exists separately from default_action because "I have no rule that matches this call" is a different question from "I have no bundle at all."
- Keep
deny(the canonical default) to fail closed if a machine cannot pull its bundle. - Set
allowto avoid bricking the agent during an outage — a deliberate availability-over-safety trade-off you opt into per project, not a silent default.
default_on_tamper picks the severity of the response when the bundle signature does not verify or a local state file was altered.
warnjust logs.denydenies the one call that triggered the check.deny-allandquarantineare aliases: the machine enters a quarantine state and denies every tool call until recovery (re-enrollment or a fresh policy pull).
Observe mode (empty policy)
When a hosted project has no rules attached and default_on_empty is left at its observe default, Control Zero runs in observe mode: it is connected, watching, and auditing — but not blocking anything. Every tool call is allowed and logged, and the surface makes the posture explicit rather than silently allowing:
OBSERVE MODE: monitoring, not enforcing — this project has no active policies, so every tool call is allowed and logged. Attach a policy to start enforcing.
Observe-mode decisions are unambiguous in the audit trail:
decisionisallow.reason_codeisOBSERVE_MODE_NO_POLICY.- the audit Policy column shows the
synthetic:OBSERVE_MODE_NO_POLICYsentinel.
This is the recommended day-one posture: enroll an agent, confirm the audit trail is flowing, see what your team's tools actually do, and only then author rules. To leave observe mode, attach any policy (one rule is enough), or set default_on_empty: deny to make an empty project fail closed.
If the client is enrolled but cannot load a bundle at all (never synced, network error, bad key, decryption failure), that is a resolution error, not an empty policy — it follows default_on_missing (which defaults to deny) and emits BUNDLE_MISSING. A failure never silently degrades into observe-mode "allow". Fail-closed is preserved on every error path.
Override precedence
The knobs can be set at three levels. Lower levels override higher levels:
- Organization -- the default for every project in the org, set from the dashboard.
- Project -- overrides the org default for a specific project.
- User YAML -- in local/unenrolled mode only, a
controlzero.yamlwith asettings:block overrides the project default.
In hosted mode the server resolves org -> project -> canonical default at bundle build time, so the SDK only sees the single resolved set of knobs. In local mode the SDK reads settings: from the YAML directly.
Example user YAML:
version: '1'
settings:
default_on_empty: observe
default_action: allow
default_on_missing: deny
default_on_tamper: quarantine
rules:
- deny: 'delete_*'
reason: 'No deletes in staging.'
The decision codes
Every decision carries a reason_code alongside the free-text reason. Automation should branch on reason_code. The free-text reason can be re-worded or translated without breaking downstream consumers.
reason_code | Meaning | Typical decision |
|---|---|---|
RULE_MATCH | A user-authored rule fired and its effect is the decision. | Whatever the rule's effect is. |
NO_RULE_MATCH | Bundle loaded with rules, but none matched this call. Decision follows default_action. | Follows default_action. |
OBSERVE_MODE_NO_POLICY | Bundle loaded cleanly with zero rules and default_on_empty is observe. The call is allowed and audited. | allow (observe mode). |
NO_ACTIVE_POLICIES | Bundle is structurally empty (zero rules) and default_on_empty is a non-observe value (deny/allow/warn). | Follows default_on_empty. |
BUNDLE_MISSING | Client is enrolled but the bundle cannot be loaded. Decision follows default_on_missing. | Follows default_on_missing. |
BUNDLE_TAMPERED | Bundle verification failed. Decision follows default_on_tamper. | deny under deny / deny-all. |
MACHINE_QUARANTINED | Machine is in the local quarantine state. Every tool call is denied until recovery. | Always deny. |
NETWORK_ERROR | Backend is unreachable and no cached bundle is available. Follows default_on_missing. | Follows default_on_missing. |
DLP_BLOCKED | A DLP rule with action: block matched the tool arguments and overrode a would-be allow. | Always deny. |
Which surface emits which
Not every surface can emit every code. The table below lists which codes a given surface is expected to produce.
| Surface | RULE_MATCH | NO_RULE_MATCH | NO_ACTIVE_POLICIES | BUNDLE_MISSING | BUNDLE_TAMPERED | MACHINE_QUARANTINED | NETWORK_ERROR | DLP_BLOCKED |
|---|---|---|---|---|---|---|---|---|
Python SDK guard() | Y | Y | Y | Y | Y | Y | Y | Y |
Node SDK guard() | Y | Y | Y | Y | Y | Y | Y | Y |
Go SDK Guard() | Y | Y | Y | Y | Y | Y | Y | Y |
| Compiled policy engine | Y | Y | Y | - | - | - | - | - |
| Gateway request guard | Y | Y | Y | Y | Y | - | Y | - |
| Coding-agent hook-check | Y | Y | Y | Y | - | Y | - | - |
| Browser extension | Y | Y | - | Y | - | - | - | Y |
OBSERVE_MODE_NO_POLICYis emitted whereverNO_ACTIVE_POLICIESis in the table above: it replacesNO_ACTIVE_POLICIESon a zero-rule bundle wheneverdefault_on_emptyis left at theobservedefault. The two are mutually exclusive for a given decision (observe-mode allow vs. an explicitly non-observe empty posture).- Server-side surfaces (Gateway, compiled engine) have no per-machine state, so they do not emit
MACHINE_QUARANTINED. - The compiled engine receives only rules + settings; it has no bundle-loading concept, so
BUNDLE_MISSING/BUNDLE_TAMPERED/NETWORK_ERRORare not reachable there. - Browser extension DLP is a separate decision surface (regex against text content), so its matches emit
DLP_BLOCKEDbut not the other DLP-adjacent codes.
Checking the effective values
In hosted mode you see the resolved knobs on the dashboard's Project Settings page.
In local mode, the knobs are read directly from the settings: block of your controlzero.yaml:
version: '1'
settings:
default_on_empty: observe
default_action: deny
default_on_missing: deny
default_on_tamper: quarantine
rules:
- allow: 'database:read'
Go callers can introspect the resolved settings at runtime via the public PolicySettings() accessor:
c, _ := controlzero.New()
s := c.PolicySettings()
fmt.Println(s.DefaultAction, s.DefaultOnMissing, s.DefaultOnTamper)
For Python and Node SDKs, the resolved values are surfaced in audit log entries and in the reason text on a deny decision, but are not yet exposed as a public client attribute. To confirm what your client is enforcing, trigger a deny and inspect the reason_code / reason fields on the returned PolicyDecision.
Synthetic policy_id sentinels (audit Policy column)
Every audit row carries a policy_id field. For user-authored rule matches this is the rule's own ID (e.g. p_01HXY...). For synthetic decisions (fail-closed denies and the observe-mode allow), the SDK and Gateway stamp policy_id with a canonical synthetic:* sentinel so the audit dashboard can render a recognizable chip and the operator can jump straight to the right troubleshooting anchor.
This is the load-bearing fix for the "every synthetic deny rendered as a blank Policy column" failure mode that previously made four very different bug classes (stale bundle, missing resource gate, vocabulary mismatch, genuine no-match) look identical in the dashboard.
policy_id sentinel | Emitted when | Paired reason_code |
|---|---|---|
synthetic:OBSERVE_MODE_NO_POLICY | Bundle loaded cleanly with zero rules and default_on_empty is observe. The call is allowed and audited (not blocked). | OBSERVE_MODE_NO_POLICY |
synthetic:NO_RULE_MATCH | Bundle loaded with rules, but no rule's actions matched the call. | NO_RULE_MATCH |
synthetic:NO_ACTIVE_POLICIES | Bundle is structurally empty (zero rules) and default_on_empty is a non-observe value. | NO_ACTIVE_POLICIES |
synthetic:BUNDLE_MISSING | Client is enrolled but no bundle could be loaded (never synced, network error, bad cache). | BUNDLE_MISSING |
synthetic:RESOURCE_GATE_SKIP | A rule's actions matched the call but its resources: gate excluded it (T83-class signature). Default deny applied. | NO_RULE_MATCH |
synthetic:QUARANTINE | Machine is in local quarantine state due to tamper detection. | MACHINE_QUARANTINED / BUNDLE_TAMPERED |
synthetic:ENGINE_UNAVAILABLE | Policy engine could not evaluate (compiled binary missing, evaluator panic, gateway misconfiguration). | BUNDLE_MISSING (gateway) / NO_RULE_MATCH (SDK panic) |
Wire-format contract: the strings above are stable across SDK versions and across the three SDKs (Python, Node, Go), the Gateway, and the dashboard frontend. Renaming any of them is a hard break across the entire surface.
Forward compatibility: the dashboard renders any unknown synthetic:* value with a generic-but-still-recognizable chip (label = the suffix lowercased), so a future SDK version can add a new sentinel before the dashboard catalog learns it.
For per-sentinel troubleshooting, see Troubleshooting: deny on every call.
Compatibility
default_action, default_on_missing, and default_on_tamper are the original three knobs; SDK versions that predate them fall back to deny / deny / warn, which matches the hard-coded pre-Phase-2 behaviour.
default_on_empty is the newest knob. SDK versions that predate it do not have an observe-mode path: on a zero-rule bundle they fall through to default_action (deny by default) and emit NO_ACTIVE_POLICIES. Observe mode therefore appears on a fresh project only once both the SDK and the backend that builds the bundle understand default_on_empty. This is a strictly safer-then-friendlier progression — an old SDK fails closed on an empty project rather than over-permitting — so upgrading remains non-breaking in both directions: a new bundle is safe for old SDKs, and a new SDK is safe against an old bundle.
Related
- Policies -- authoring rules.
- Deny-list recipe -- a copy-paste
default_action: allowpolicy that blocks only the dangerous few. - Dev warns, prod denies -- one rule set,
default_action: warnin dev anddenyin prod. - Why is my tool denied? -- the fast fix when a hosted policy blocks a tool you expected to allow.
- Feature Availability -- which surfaces and tiers support which knobs.