Skip to main content

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.

The one distinction that surprises people

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.

Availability

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.

KnobValuesFires whenDefault
default_on_emptyobserve | deny | allow | warnBundle loaded successfully but it has zero rules (no policy is attached yet).observe
default_actiondeny | allow | warnBundle loaded with one or more rules, but none of them matched the call.deny
default_on_missingdeny | allowClient is enrolled but no bundle can be loaded (never synced, network error, bad key).deny
default_on_tamperwarn | deny | deny-all | quarantineBundle verification fails, or machine is in local quarantine.warn
Empty vs. no-match are different questions

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 deny if an empty project should fail closed (e.g. a regulated org where "no policy" must mean "no tool calls").
  • Set allow or warn for 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 allow for 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-blocks rm, DROP, disk wipes, and writes to SSH-key paths.
  • Set warn during a soft rollout to see what the blocks would be before flipping to deny.

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 allow to 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.

  • warn just logs.
  • deny denies the one call that triggered the check.
  • deny-all and quarantine are 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:

  • decision is allow.
  • reason_code is OBSERVE_MODE_NO_POLICY.
  • the audit Policy column shows the synthetic:OBSERVE_MODE_NO_POLICY sentinel.

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.

Observe mode applies to a successfully loaded, empty bundle only

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:

  1. Organization -- the default for every project in the org, set from the dashboard.
  2. Project -- overrides the org default for a specific project.
  3. User YAML -- in local/unenrolled mode only, a controlzero.yaml with a settings: 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_codeMeaningTypical decision
RULE_MATCHA user-authored rule fired and its effect is the decision.Whatever the rule's effect is.
NO_RULE_MATCHBundle loaded with rules, but none matched this call. Decision follows default_action.Follows default_action.
OBSERVE_MODE_NO_POLICYBundle loaded cleanly with zero rules and default_on_empty is observe. The call is allowed and audited.allow (observe mode).
NO_ACTIVE_POLICIESBundle is structurally empty (zero rules) and default_on_empty is a non-observe value (deny/allow/warn).Follows default_on_empty.
BUNDLE_MISSINGClient is enrolled but the bundle cannot be loaded. Decision follows default_on_missing.Follows default_on_missing.
BUNDLE_TAMPEREDBundle verification failed. Decision follows default_on_tamper.deny under deny / deny-all.
MACHINE_QUARANTINEDMachine is in the local quarantine state. Every tool call is denied until recovery.Always deny.
NETWORK_ERRORBackend is unreachable and no cached bundle is available. Follows default_on_missing.Follows default_on_missing.
DLP_BLOCKEDA 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.

SurfaceRULE_MATCHNO_RULE_MATCHNO_ACTIVE_POLICIESBUNDLE_MISSINGBUNDLE_TAMPEREDMACHINE_QUARANTINEDNETWORK_ERRORDLP_BLOCKED
Python SDK guard()YYYYYYYY
Node SDK guard()YYYYYYYY
Go SDK Guard()YYYYYYYY
Compiled policy engineYYY-----
Gateway request guardYYYYY-Y-
Coding-agent hook-checkYYYY-Y--
Browser extensionYY-Y---Y
  • OBSERVE_MODE_NO_POLICY is emitted wherever NO_ACTIVE_POLICIES is in the table above: it replaces NO_ACTIVE_POLICIES on a zero-rule bundle whenever default_on_empty is left at the observe default. 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_ERROR are not reachable there.
  • Browser extension DLP is a separate decision surface (regex against text content), so its matches emit DLP_BLOCKED but 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 sentinelEmitted whenPaired reason_code
synthetic:OBSERVE_MODE_NO_POLICYBundle 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_MATCHBundle loaded with rules, but no rule's actions matched the call.NO_RULE_MATCH
synthetic:NO_ACTIVE_POLICIESBundle is structurally empty (zero rules) and default_on_empty is a non-observe value.NO_ACTIVE_POLICIES
synthetic:BUNDLE_MISSINGClient is enrolled but no bundle could be loaded (never synced, network error, bad cache).BUNDLE_MISSING
synthetic:RESOURCE_GATE_SKIPA rule's actions matched the call but its resources: gate excluded it (T83-class signature). Default deny applied.NO_RULE_MATCH
synthetic:QUARANTINEMachine is in local quarantine state due to tamper detection.MACHINE_QUARANTINED / BUNDLE_TAMPERED
synthetic:ENGINE_UNAVAILABLEPolicy 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.