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:
- Generate a fresh signing keypair locally. The private key
is written to
~/.controlzero/machine.keywith mode 0600 and never leaves the machine. The public half is sent to Control Zero in step 2. - POST
/api/enrollwith: the enrollment token, a stable machine fingerprint (hostname + OS + arch hash), and the public key. The backend validates the token, mints a server-assignedmachine_idUUID, 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, orquarantine). See Tamper Detection and Enforcement.
- Persist state to disk.
~/.controlzero/enrollment.jsongets themachine_id,org_id, hostname, fingerprint, public key PEM, the currentpolicy_version, the org signing public key, and thetamper_behaviorsetting.
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.jsonshape - Write the same
~/.controlzero/machine.keyPKCS8 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:
| Command | What it does |
|---|---|
controlzero enroll --token T | First-run setup: generate keypair, exchange token, persist state |
controlzero heartbeat | Send one signed heartbeat (verification / debugging) |
controlzero policy-pull | Pull 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_versionas anIf-None-MatchETag — 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.