Skip to main content

SDK Enrollment

Managed mode is the path where your SDK installation registers itself with a Control Zero org and gets a stable identity that the dashboard can manage. After enrollment the SDK can pull DLP rules from the org, push audit decisions to the org's analytical store, and show up in the org's fleet view.

If you don't want any of that — just run the SDK with a local policy file and no signup — see the local-only mode guide in the SDK documentation.

What enrollment does

controlzero enroll --token TOKEN runs in three steps:

  1. Generate a fresh signing keypair locally. The private key is written to ~/.controlzero/machine.key with mode 0600 and never leaves the machine. The public half is sent to Control Zero in step 2.
  2. POST /api/enroll with: the enrollment token, a stable machine fingerprint (hostname + OS + arch hash), and the public key. The backend validates the token, mints a server-assigned machine_id UUID, stores the public key against it, and returns the new identity along with two org-level settings:
    • signing_public_key -- the org's cryptographic signing key used to verify policy bundles on every pull.
    • tamper_behavior -- the org's tamper detection mode (warn, deny, deny-all, or quarantine). See Tamper Detection and Enforcement.
  3. Persist state to disk. ~/.controlzero/enrollment.json gets the machine_id, org_id, hostname, fingerprint, public key PEM, the current policy_version, the org signing public key, and the tamper_behavior setting.

After step 3, every subsequent SDK call uses the on-disk private key to sign requests with the machine signing keypair. There is no static bearer token to leak.

The org signing public key returned at enrollment is stored locally and used to verify the cryptographic signature on every policy bundle pulled from the backend. If verification fails, the SDK follows the org's tamper_behavior setting (default: warn). See Tamper Detection and Enforcement for details on each mode.

Wire format (every SDK uses the same)

The Python, Node, and Go enrollment clients are interchangeable. A machine enrolled with one client can be heartbeated by another. They all:

  • Write the same ~/.controlzero/enrollment.json shape
  • Write the same ~/.controlzero/machine.key PKCS8 PEM
  • Use the same canonical signed string format:
{machine_id}\n{timestamp}\n{METHOD}\n{path}\n{sha256_hex(body)}
  • Send the same headers:
X-CZ-Machine-ID: {UUID}
X-CZ-Timestamp: {unix-seconds}
X-CZ-Signature: {base64-signing-key-signature}

This is by design. A team that uses a mix of Python and Node microservices can enroll each instance with whatever client is already installed; the org's dashboard treats them all the same.

CLI commands

Each SDK exposes the same three commands:

CommandWhat it does
controlzero enroll --token TFirst-run setup: generate keypair, exchange token, persist state
controlzero heartbeatSend one signed heartbeat (verification / debugging)
controlzero policy-pullPull the latest DLP policy bundle (uses If-None-Match)

In normal operation a long-running daemon sends heartbeats every 5 minutes automatically. You only need to run heartbeat and policy-pull manually for verification or debugging.

Python

pip install 'controlzero[hosted]'
controlzero enroll --token cz_enroll_a3f1c2d8e7b9f4a5...

The hosted extras pull in cryptography (for the keypair) and httpx (for the network calls). Both are widely deployed dependencies; the only platform requirement is Python 3.9+.

Programmatic API:

from controlzero.enrollment import enroll, heartbeat, pull_policy

# First run
state = enroll(
api_url="https://api.controlzero.ai",
token="cz_enroll_xxx",
)
print(f"machine_id={state.machine_id}")

# Subsequent calls (uses persisted state automatically)
resp = heartbeat()
print(f"server says policy version is {resp['policy_version']}")

# Pull policy if it changed
bundle = pull_policy()
if bundle is not None:
print(f"new policy version: {bundle['policy_version']}")
for rule in bundle['rules']:
print(f" - {rule['name']} ({rule['action']})")
else:
print("policy is up to date")

Node.js

npm install -g @controlzero/sdk
controlzero enroll --token cz_enroll_a3f1c2d8e7b9f4a5...

Zero new dependencies. The Node client uses Node 18+ built-ins only: node:crypto for the keypair and signing, native fetch for the network.

Programmatic API:

import { enroll, heartbeat, pullPolicy } from '@controlzero/sdk';

// First run
const state = await enroll({
apiUrl: 'https://api.controlzero.ai',
token: 'cz_enroll_xxx',
});
console.log(`machine_id=${state.machine_id}`);

// Subsequent calls
const resp = await heartbeat();
console.log(`server says policy version is ${resp.policy_version}`);

// Pull policy if it changed
const bundle = await pullPolicy();
if (bundle !== null) {
console.log(`new policy version: ${bundle.policy_version}`);
for (const rule of bundle.rules) {
console.log(` - ${rule.name} (${rule.action})`);
}
} else {
console.log('policy is up to date');
}

Go

go install controlzero.ai/sdk/go/cmd/controlzero@latest
controlzero enroll --token cz_enroll_a3f1c2d8e7b9f4a5...

Stdlib only. The Go client uses standard library cryptographic signing and net/http. No external dependencies.

Programmatic API:

import (
"context"
"fmt"

cz "controlzero.ai/sdk/go"
)

// First run
state, err := cz.Enroll(context.Background(), cz.EnrollOptions{
APIURL: "https://api.controlzero.ai",
Token: "cz_enroll_xxx",
})
if err != nil { panic(err) }
fmt.Printf("machine_id=%s\n", state.MachineID)

// Subsequent calls
resp, err := cz.Heartbeat(context.Background(), state, "", nil)
if err != nil { panic(err) }
fmt.Printf("server says policy version is %d\n", resp.PolicyVersion)

// Pull policy if it changed
bundle, err := cz.PullPolicy(context.Background(), state, "")
if err != nil { panic(err) }
if bundle != nil {
fmt.Printf("new policy version: %d\n", bundle.PolicyVersion)
for _, rule := range bundle.Rules {
fmt.Printf(" - %s (%s)\n", rule.Name, rule.Action)
}
}

Re-enrollment

Re-running controlzero enroll on the same machine with a different token converges on the same machine_id because the backend dedups by (org_id, fingerprint_hint). This is by design: an MDM thundering herd (the same script pushed to every laptop on a schedule) doesn't create duplicate fleet entries.

Re-enrollment also clears quarantine state. If a machine has been quarantined due to a tamper detection event, running controlzero enroll with a valid token resets the machine to normal operation, pulls a fresh verified policy bundle, and resumes enforcement with the org's current tamper_behavior setting.

If you want to force a fresh identity (e.g., the machine is being repurposed for a new owner), delete the local state first:

rm -rf ~/.controlzero/
controlzero enroll --token NEW_TOKEN_HERE

OS keystore (future)

In the current release, the private key lives at ~/.controlzero/machine.key with mode 0600. A follow-up release will add OS keystore integration via:

  • macOS: Keychain
  • Windows: DPAPI
  • Linux: secret-service / GNOME Keyring

The on-disk PEM stays as the bottom of the chain for environments where the keystore isn't available (CI runners, headless containers, air-gapped servers).

What's transmitted vs what stays local

Transmitted to Control Zero:

  • The enrollment token (one time, at enroll)
  • The machine's public key PEM (one time, at enroll)
  • Hostname, OS, arch (one time, at enroll, used for fleet display)
  • Heartbeat metadata (machine_id, current plugin versions) — every 5 min
  • Audit batches (tool_name, decision, rule_id, timestamp) — streamed
  • The current policy_version as an If-None-Match ETag — every 5 min

Never transmitted:

  • The private key (stays in ~/.controlzero/machine.key)
  • Full prompt or response bodies (only metadata is sent in audits)
  • Local file contents (only the matched span text for audit rules that block, and only if the SDK is configured to include it — defaults OFF for privacy)

Verifying enrollment end-to-end

After running controlzero enroll, run:

controlzero heartbeat

Expected output:

Heartbeat OK.
server_time : 2026-04-08T15:30:42Z
policy_version : 12

Then check the dashboard at Governance → Fleet. Your machine should appear in the list with status online (last_seen under 2 min) within seconds of the heartbeat.

If neither happens, see Troubleshooting in the admin guide.

Disabling enrollment in production

If you need to disable enrollment for emergency reasons (e.g., the backend is overloaded and you want to stop new machines from trying to enroll), set the env var on the backend:

ENROLLMENT_API_ENABLED=false

All enrollment endpoints (/api/enroll, /api/heartbeat, /api/policy, /api/audit) return 503 FEATURE_DISABLED immediately. The route is still registered so a probe can detect its existence; only the response body changes.

The flag defaults to true in production so the feature is on by default. Setting it to false requires a backend restart.