Skip to main content

AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.

Project description

controlzero

AI agent governance for Python. Policies, audit, and observability for tool calls. Works locally with no signup.

v1.0.0 is a complete rewrite. If you depend on control-zero<1.0.0 (the hosted-mode SDK), pin your requirement: control-zero<1.0.0 to stay on the legacy v0.3.x. The new v1.0.0+ is a local-first SDK with a different API surface; see the migration guide for details.

Hello World

from controlzero import Client

cz = Client(policy={
    "rules": [
        {"deny":  "delete_*", "reason": "Hello World: deletes are blocked"},
        {"allow": "*",        "reason": "Hello World: everything else is fine"},
    ]
})

print(cz.guard("delete_file", {"path": "/tmp/foo"}).decision)  # "deny"
print(cz.guard("read_file",   {"path": "/tmp/foo"}).decision)  # "allow"

11 lines. No API key. No signup. Run it.

Install

pip install controlzero

Why

Your AI agents call tools. Some of those tools should never be called by an agent without a human in the loop. controlzero is the policy layer between the model's output and the tool execution. Decisions are fail-closed by default.

You can use it offline with a local YAML or JSON file or Python dict. When you want to share policies across a team or get a hosted audit dashboard, sign up at controlzero.ai and set CONTROLZERO_API_KEY.

Quickstart with the CLI

# 1. Generate a starter policy file with examples and comments
controlzero init

# 2. Edit controlzero.yaml in your editor

# 3. Validate it
controlzero validate

# 4. Test a tool call against the policy
controlzero test delete_file

The generated controlzero.yaml is the tutorial. It ships with annotated rules covering the common patterns: allow lists, deny lists, wildcards, and the catch-all.

Templates available (controlzero init -t <name>):

  • generic — Hello World template (default)
  • rag — RAG agent template (block exfiltration)
  • mcp — MCP server template
  • cost-cap — model allow-listing and cost guards
  • claude-code — Claude Code hook starter
  • langchain — LangChain tool guardrails
  • crewai — CrewAI starter policy
  • cursor — Cursor / editor hook starter
  • autogen — AutoGen starter policy
  • codex-cli — Codex CLI hook starter
  • gemini-cli — Gemini CLI hook starter
  • kiro — Kiro (AWS) hook starter (CLI: GA; IDE: limited preview)
  • antigravity — Google Antigravity (IDE + agy CLI) hook starter (Beta)

Loading a policy

Three ways:

from controlzero import Client

# From a Python dict
cz = Client(policy={
    "rules": [
        {"deny": "delete_*"},
        {"allow": "read_*"},
    ]
})

# From a YAML file
cz = Client(policy_file="./controlzero.yaml")

# From an environment variable
# (set CONTROLZERO_POLICY_FILE=./controlzero.yaml)
cz = Client()

If a policy file exists in the current directory it is picked up automatically -- controlzero.yaml, controlzero.yml, or controlzero.json are auto-detected in that order (first existing wins). No environment variable needed. The file may be YAML or JSON; both use the identical schema.

Policy schema

version: '1'
rules:
  # Block any tool whose name starts with "delete_"
  - deny: 'delete_*'
    reason: 'Deletes need human approval'

  # Allow specific known-good tools
  - allow: 'search'
  - allow: 'read_*'

  # tool:method syntax
  - allow: 'github:list_*'
  - deny: 'github:delete_repo'

  # Catch-all
  - deny: '*'
    reason: 'Default deny'

Rules are evaluated top to bottom. The first match wins. If no rule matches, the call is denied (fail-closed).

Tamper detection and quarantine

The policy YAML supports a settings: section that controls how the SDK responds when it detects that the local policy file has been modified outside of normal channels (manual edits, unexpected hash changes, etc.):

version: '1'
settings:
  tamper_behavior: warn # Options: warn | deny | deny-all | quarantine
rules:
  - deny: 'delete_*'
  - allow: '*'
Mode Behavior
warn Log a warning but continue evaluating rules normally.
deny Deny the current tool call that triggered the tamper check.
deny-all Deny all tool calls and place the machine in quarantine until recovered.
quarantine Same as deny-all, plus report a tamper alert to the backend dashboard.

Quarantine recovery. When a machine enters quarantine (deny-all or quarantine), every tool call is denied until you re-establish trust with one of these commands:

controlzero enroll
controlzero policy-pull
controlzero sign-policy

Org-level policy signing. When a machine is enrolled via controlzero enroll, it receives the organization's signing public key. Policy bundles pulled from the backend are cryptographically signed and verified by the SDK automatically. No extra configuration is required.

Tamper alert reporting. In quarantine mode, the SDK reports a tamper alert to the Control Zero backend so your team can see it on the dashboard.

Local audit log

Every decision (allow and deny) is written to a local audit log in every mode — local, hybrid, and hosted. The local log is never skipped, so controlzero tail, cz debug-bundle, and the tamper hash-chain always have a record to read.

controlzero tail

Default paths:

  • Local / unenrolled mode (no API key): ./controlzero.log, with daily rotation and 30-day retention.
  • Hosted mode (CONTROLZERO_API_KEY set): ~/.controlzero/audit.log when you do not pass an explicit log_path. Local audit is written in addition to the remote dashboard sink, not instead of it — the remote sink is layered on top. In hosted mode, PII and financial DLP matched_text is redacted from the local plaintext row (the secret category is already hashed); the remote sink keeps full fidelity.

Configure rotation via the client (honoured in any mode):

cz = Client(
    policy_file="./controlzero.yaml",
    log_path="./logs/controlzero.log",
    log_rotation="10 MB",        # rotate at 10 MB, or "daily", or "1 hour"
    log_retention="30 days",
    log_compression="gz",        # gzip rotated files
    log_format="json",           # or "pretty"
)

Hybrid mode

Default (T103, 2026-05-12): when CONTROLZERO_API_KEY is set, the hosted (dashboard) policy wins. Pass CONTROLZERO_LOCAL_OVERRIDE=1 to force the local file as a debug fallback.

If you BOTH set an API key AND pass a policy= / policy_file= arg to Client(...), the explicit local arg wins (caller is intentional) and you get a loud WARN log on init:

WARNING: controlzero: explicit local policy overrides the hosted bundle. ...

This makes accidental prod bypass impossible to miss. For prod environments, opt into strict mode to raise instead:

cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
# HybridModeError: explicit local policy overrides the hosted bundle ...

Coding agent hooks

controlzero hook-check runs inside Claude Code, Gemini CLI, and Codex CLI on every tool use and evaluates the call against your policy before it fires. It extracts a canonical tool:method from the tool arguments so rules can target database:SELECT vs database:DROP, or allow Bash:git while denying Bash:rm. Multi-statement SQL and compound shell commands are resolved to the most dangerous token, so a SELECT ... ; DROP TABLE users; payload matches database:DROP, not database:SELECT. See Hook action extraction for the full extraction rules, security model, and per-tool examples.

Framework examples

Full integration guides at docs.controlzero.ai/sdk/integrations:

  • LangChain
  • LangGraph
  • CrewAI
  • OpenAI Agents SDK
  • Anthropic tool use
  • Pydantic AI
  • AutoGen
  • MCP servers
  • Raw HTTP / no framework

Hosted mode

When you want a dashboard, audit search, team policies, and approval workflows, sign up at controlzero.ai and set the API key:

import os
os.environ["CONTROLZERO_API_KEY"] = "cz_live_..."

from controlzero import Client
cz = Client()  # picks up the API key from env, audit ships remote

Human-in-the-Loop approvals

Approvals let a policy block a tool call until a human approver decides allow or deny. An agent calls client.request_approval(decision, ...) whenever guard() returns a deny that is tagged escalate_on_deny: true, then waits on the returned PendingApproval for the human to respond.

Basic flow:

from controlzero import Client

cz = Client(api_key="cz_live_...")  # approvals require hosted mode

decision = cz.guard("delete_file", {"path": "/etc/passwd"})
if decision.decision == "deny" and getattr(decision, "hitl_eligible", False):
    pending = cz.request_approval(
        decision,
        message="agent wants to delete /etc/passwd; please confirm",
        timeout_s=300,
    )

    # PendingApproval.wait() requires you to inject a `poll_fn`,
    # a callable that returns the latest backend snapshot of the
    # approval request. In 1.6.0 the SDK does NOT ship a built-in
    # HTTP poller; you wire one yourself (or use the helper that
    # ships with get_secret. See "Secret reads with approvals" below).
    import httpx
    api_url = "https://api.controlzero.ai"  # or your self-managed host

    def poll_fn(request_id: str) -> dict:
        resp = httpx.get(
            f"{api_url}/api/approval-requests/{request_id}",
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10,
        )
        resp.raise_for_status()
        return resp.json()

    # Block until the human approves, denies, or the SLA expires.
    resolved = pending.wait(poll_fn)
    if resolved.status == "approved":
        # proceed with the gated action
        ...
    else:
        # denied or timed_out, abort the tool call
        ...

wait() blocks the calling thread. For async code, use wait_async(), same contract, but the poll callable can be async def or a sync function (sync calls are dispatched to a thread executor so the event loop never blocks):

async def async_poll(request_id: str) -> dict:
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"{api_url}/api/approval-requests/{request_id}",
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10,
        )
        resp.raise_for_status()
        return resp.json()

resolved = await pending.wait_async(async_poll)

1.7.0 ships client.get_secret_poll_fn() as a built-in poller helper for the secret-read approval path. The general request_approval() flow still takes an explicit poll_fn so callers can plug their own HTTP stack.

Mock backend for tests

The SDK ships an in-process MockApprovalBackend so tests can exercise approval paths without standing up the real backend. Wire it into the polling loop by passing poll_fn:

from controlzero import PendingApproval
from controlzero.hitl.mock import MockApprovalBackend

backend = MockApprovalBackend("approve_after_2s", delay_s=0.05)
created = backend.create_request({"canonical_action": "delete_file"})
pending = PendingApproval(
    request_id=created["request_id"],
    idempotency_key="test-key",
    status="pending",
    created_at=created["created_at"],
    expires_at=created["expires_at"],
)
resolved = pending.wait(poll_fn=lambda rid: backend.get_request(rid))
assert resolved.status == "approved"

The five supported modes are approve_after_2s, approve_timed_after_2s, approve_forever_after_2s, deny_after_2s, and timeout.

Identity requirement

Every approval request must carry the operator email so the backend can route to a real person and stamp identity provenance on the grant. Set it once via the CLI:

controlzero install <agent> --email you@example.com

If the email is missing, request_approval() raises HITLIdentityRequired (E1707) before any HTTP traffic.

Secret reads with approvals

When a policy gates a secret behind approval, client.get_secret(name) raises SecretApprovalRequired (E1710) carrying a pending attribute the caller waits on:

from controlzero.errors import SecretApprovalRequired

try:
    value = cz.get_secret("PROD_DB_PASSWORD")
except SecretApprovalRequired as exc:
    resolved = exc.pending.wait()
    if resolved.status == "approved":
        value = cz.get_secret("PROD_DB_PASSWORD")  # retry now that grant exists
    else:
        raise  # abort

Exception classes

The 11 approval-related exception codes raised by this surface. Class names retain the HITL prefix because they are part of the stable public SDK API:

Code Class Meaning
E1701 HITLTimeoutError Approver did not decide before timeout_s elapsed.
E1702 HITLBackendUnreachableError POST to the approval endpoint failed after retries.
E1703 HITLPolicyVersionConflictError SDK bundle is missing the rule that triggered the request.
E1704 HITLNotConfiguredError Org has no approval settings row configured.
E1705 HITLNoApproverAvailable Approver pool is empty or no member is active.
E1706 HITLIdentityNotInOrg Operator email is not a member of the API key's org.
E1707 HITLIdentityRequired No operator email set on this install.
E1708 HITLIdentityClaimRejected Backend rejected the identity claim.
E1709 SecretValueLeakInPayload Outbound payload contains a secret-shaped string. Aborted.
E1710 SecretApprovalRequired Secret read requires approval; wait on exc.pending.
E1711 SecretNotFound Named secret does not exist in the configured vault.

Full reference and runbooks: docs.controlzero.ai/hitl.

License

Apache 2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

controlzero-1.10.1.tar.gz (625.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

controlzero-1.10.1-py3-none-any.whl (412.3 kB view details)

Uploaded Python 3

File details

Details for the file controlzero-1.10.1.tar.gz.

File metadata

  • Download URL: controlzero-1.10.1.tar.gz
  • Upload date:
  • Size: 625.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for controlzero-1.10.1.tar.gz
Algorithm Hash digest
SHA256 27b841302ba607eb3d0462b20929784e97a225cc6f372bfdab25cd39b58107f3
MD5 70684d9be171cfe810af82dccf57488e
BLAKE2b-256 060df1d15f3e0ee9f15046087be8a219b39229eb2457b3ed915aa42d9b78c4bc

See more details on using hashes here.

File details

Details for the file controlzero-1.10.1-py3-none-any.whl.

File metadata

  • Download URL: controlzero-1.10.1-py3-none-any.whl
  • Upload date:
  • Size: 412.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for controlzero-1.10.1-py3-none-any.whl
Algorithm Hash digest
SHA256 bb657a301a8c5bcaacbdec1b883a6fa24c373e1cc9feb960b21d51757dbb6f51
MD5 d7f7b3543e4e8e1e4f884f46a826329e
BLAKE2b-256 9bd1c5567e0f28c37bf121fe84a2d187b33c0637b913203ffd40415d36bd0c12

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page