Debugging your integration
A practical checklist for diagnosing policy decisions, bundle freshness, and enforcement behavior across the Python, Node, and Go SDKs. Pair this with Troubleshooting deny-on-every-call for the specific deny-deny scenarios.
Quick triage
Before reaching for logs or a debugger, run the bundle inspector. The SDK ships with a self-serve command that decrypts the cached policy bundle, prints every rule the SDK would evaluate, and optionally simulates a guard() call against it.
# Print the policies + rules the SDK actually loaded.
controlzero debug bundle
# Dry-run a real call. Args may be a JSON object OR `key=value` pairs
# (values may contain spaces -- everything between key= boundaries
# is treated as the value).
controlzero debug bundle --simulate "database read sql=SELECT id FROM orders"
Sample output:
Control Zero -- bundle on disk
cache prefix : cz_test_loca
bootstrap path : ~/.controlzero/cache/bootstrap-cz_test_loca.json
bundle path : ~/.controlzero/cache/bundle-cz_test_loca.bin
project_id : proj_test_fixture
org_id : org_test_fixture
key_version : 1
schema_version : 1
bundle_id : bundle-fixture-001
created_at : 2024-04-30T16:00:00+00:00
expires_at : 2024-05-01T16:00:00+00:00
default_action : deny
default_on_missing: deny
default_on_tamper : warn
Policies (2)
policy 0
id : pol-db-read
name : Allow database reads
is_enabled: true
priority : 10
rules : 1
rule 0
id : r-db-read-allow
effect : allow
actions : ['database:read']
...
Simulation
tool : database
method : read
args : {'sql': 'SELECT id FROM orders'}
DECISION : ALLOW
reason_code : RULE_MATCH
reason : Reads on the orders table are permitted.
rules eval'd: 1
What this answers in one shot:
- Is the bundle even on disk? A missing bootstrap or bundle file produces an explicit error pointing at the path the SDK looked for.
- Did the right policy version make it through? The header shows
bundle_id,created_at, and the resolveddefault_*knobs. - Did the rule I expect actually load? The Policies section enumerates each rule with id, effect, actions, resources, and conditions.
- Why did this specific call deny?
--simulateruns the request through the same evaluator the SDK uses and shows the decision, reason code, and matched rule.
The output is intentionally support-thread-safe: the encryption key, signing public key, and API key are never printed.
Enable verbose logging
Python
import logging
logging.basicConfig(level=logging.DEBUG)
from controlzero import Client
cz = Client(api_key="cz_live_...")
You can also flip just the controlzero logger via the env var:
export CZ_DEBUG=1
python my_agent.py
CZ_DEBUG=1 (or true/yes/on) flips the controlzero logger to DEBUG at construction. Cheap escape hatch when you cannot edit the code.
Node.js
import { Client } from '@controlzero/sdk';
// SDK logs go to stderr by default. To see them, set the standard
// Node debug-logging env var:
// DEBUG=controlzero:* node my_agent.js
const cz = await Client.create({ apiKey: 'cz_live_...' });
Go
import (
"log"
controlzero "controlzero.ai/sdk/go"
)
// The Go SDK uses the standard `log` package. Capture output normally.
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
cz, _ := controlzero.New(controlzero.WithAPIKey("cz_live_..."))
Understand the action evaluated
Every guard() call evaluates a tool:method action string. For database calls, the SDK also derives a canonical SQL semantic class from the sql argument and evaluates that in parallel. So a single call may match against two action shapes:
| Caller | Per-call action | Canonical class |
|---|---|---|
cz.guard("database", method="query", ...) | database:query | database:read |
cz.guard("database", method="execute", ...) | database:execute | database:write |
cz.guard("database", method="delete", ...) | database:delete | database:write |
cz.guard("database", method="DROP", args={"sql": "DROP TABLE x"}) | database:DROP | database:admin |
A rule keyed on either form fires. Legacy names (database:query, database:execute, database:delete) and canonical names (database:read, database:write, database:admin) both work.
Audit log columns
Every decision is logged. The columns most useful for debugging:
| Column | Meaning |
|---|---|
decision | allow, deny, or warn. The final outcome. |
tool | Tool name passed to guard() (e.g., database). |
method | Method name passed to guard() (e.g., query). |
policy_id | UUID of the rule that matched. Empty for synthetic denies (no rule fired). |
policy_matched | Human-readable name of the matched rule. |
reason_code | Machine-readable enum: RULE_MATCH, NO_RULE_MATCH, NO_ACTIVE_POLICIES, BUNDLE_MISSING, BUNDLE_TAMPERED, MACHINE_QUARANTINED, NETWORK_ERROR, DLP_BLOCKED. |
reason | Free-text explanation. Safe to display to users. |
See Enforcement Behavior for the full reason_code reference and which surface emits which codes.
Bundle on disk
Hosted-mode SDKs cache the signed policy bundle locally so they keep working offline. The cache layout under ~/.controlzero/cache/:
| File | Contents |
|---|---|
bootstrap-<prefix>.json | Project ID, org ID, encryption key, signing public key. |
bundle-<prefix>.bin | Raw signed bundle bytes. |
bundle-<prefix>.meta | ETag and checksum for conditional fetches. |
The <prefix> is the first 12 characters of the API key. The bundle is encrypted at rest and signed; the SDK verifies the signature before every load.
To force a fresh bundle pull from the dashboard:
# Python
cz.refresh()
// Node
await cz.refreshPolicies();
As a last resort, delete the cache and re-bootstrap on the next call:
rm -rf ~/.controlzero/cache/
The next guard() call will re-fetch keys and bundle from the backend.
When to contact support
Open a support ticket when:
- A
reason_codeyou do not recognize keeps appearing. - The bundle on disk is older than a published version and
refresh()does not pull a newer one. - You see
BUNDLE_TAMPEREDwithout an explanation.
Include in the ticket:
- The SDK language and version (e.g.,
python 1.4.6,@controlzero/sdk 1.8.2,go 1.4.0). - The audit row
reason_code,tool,method, andpolicy_idcolumns for the failing call. - The policy YAML (or a sanitised excerpt) of the rule that should match.
- A minimal repro: the
guard()call shape with arguments redacted to keys only. - Whether
refresh()returnedTrue(bundle changed) orFalse(no change) after the last dashboard publish.