Skip to main content

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 resolved default_* 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? --simulate runs 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:

CallerPer-call actionCanonical class
cz.guard("database", method="query", ...)database:querydatabase:read
cz.guard("database", method="execute", ...)database:executedatabase:write
cz.guard("database", method="delete", ...)database:deletedatabase:write
cz.guard("database", method="DROP", args={"sql": "DROP TABLE x"})database:DROPdatabase: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:

ColumnMeaning
decisionallow, deny, or warn. The final outcome.
toolTool name passed to guard() (e.g., database).
methodMethod name passed to guard() (e.g., query).
policy_idUUID of the rule that matched. Empty for synthetic denies (no rule fired).
policy_matchedHuman-readable name of the matched rule.
reason_codeMachine-readable enum: RULE_MATCH, NO_RULE_MATCH, NO_ACTIVE_POLICIES, BUNDLE_MISSING, BUNDLE_TAMPERED, MACHINE_QUARANTINED, NETWORK_ERROR, DLP_BLOCKED.
reasonFree-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/:

FileContents
bootstrap-<prefix>.jsonProject ID, org ID, encryption key, signing public key.
bundle-<prefix>.binRaw signed bundle bytes.
bundle-<prefix>.metaETag 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_code you 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_TAMPERED without an explanation.

Include in the ticket:

  1. The SDK language and version (e.g., python 1.4.6, @controlzero/sdk 1.8.2, go 1.4.0).
  2. The audit row reason_code, tool, method, and policy_id columns for the failing call.
  3. The policy YAML (or a sanitised excerpt) of the rule that should match.
  4. A minimal repro: the guard() call shape with arguments redacted to keys only.
  5. Whether refresh() returned True (bundle changed) or False (no change) after the last dashboard publish.