Skip to main content

Coding Assistant Hooks

Supported modes: Hosted Hybrid Local Available in: Free Solo Teams (org-wide enrollment: Teams)

Coding assistant hooks provide deterministic enforcement on Claude Code, Gemini CLI, Cursor IDE, and Kiro CLI. For every tool call covered by the host's pre-execution hook, a deny stops the call before it executes. No code changes, proxy, or signup is required for local mode.

Coverage is declared per event, not per host: each audit row identifies the surface and the enforcement coverage for that specific event. The capability matrix is derived from the adapters' own capability declarations. This page is written by hand; controlzero coverage --json exports those declarations for your installation and is the authority if the two ever disagree. A bare controlzero coverage prints only a short per-host summary. Policy resolution fails closed by default: a missing policy bundle is denied rather than guessed at. (Age is not itself a deny trigger in the SDK -- a cached bundle with real rules keeps enforcing while a refresh is retried, which is the intended last-known-good behaviour.) A bundle that fails signature verification is never loaded: during a background refresh the agent keeps enforcing the last known good policy, and at startup there is nothing to fall back to, so calls are denied. Coverage is narrower on four surfaces, each for a different reason: Codex CLI and Cursor CLI (cursor-agent) cover the execute family only, and never receive the other eight; on Antigravity the host may treat a deny as advisory in its headless modes, so a denied call can still run there; Kiro IDE routes no tool call to a deciding event, so it does not enforce. See Enforcement Coverage for the per-tool detail.

Use the SDK or gateway for LLM API calls

Hooks govern an assistant's tool calls (Bash, Read, Write), not requests to the LLM provider. For application-level LLM API calls, use the SDK or gateway.

What Hooks Do

When a coding assistant invokes a tool (run a shell command, write a file, call an API), the hook intercepts the call and:

  1. Evaluates the tool name against your policy rules (first-match-wins, fail-closed default).
  2. Scans tool arguments for DLP violations (PII, secrets, proprietary patterns).
  3. Blocks denied calls before execution and returns a branded [Control Zero] reason to the agent.
  4. Logs every decision and its execution status to a complete audit trail.
  5. Optionally syncs audit data to your Control Zero backend when the machine is enrolled.
Developer uses Claude Code / Gemini CLI / Codex CLI
|
v
Tool call triggered (e.g., Bash, write_file, shell)
|
v
controlzero hook-check (stdin: tool call JSON)
|
+---> Policy evaluation (first-match-wins)
| |
| +---> ALLOW -> exit 0
| | stderr: [Control Zero] Allowed: <tool>
| |
| +---> DENY -> exit 2
| stdout: JSON block decision (Claude Code)
| stderr: [Control Zero] <reason> (Gemini/Codex)
|
+---> DLP scan on tool arguments
| |
| +---> PII/secret found + action=block -> DENY
|
+---> Tamper check (if policy is signed)
| |
| +---> HMAC mismatch -> flag in audit trail
|
+---> Audit log entry written (~/.controlzero/audit.log)

Supported Agents

AgentHook EventConfig LocationStatus
Claude CodePreToolUse, UserPromptSubmit~/.claude/settings.jsonA deny rule stops the call before it runs, across every tool family. One of only two hosts that can mask: through the Python SDK it honours hookSpecificOutput.updatedInput, so a DLP mask rule proceeds with redacted arguments
Gemini CLIBeforeTool, BeforeAgent~/.gemini/settings.jsonA deny rule stops the call before it runs, across every tool family. One of only two hosts that can mask: through the Python SDK it honours hookSpecificOutput.tool_input, so a DLP mask rule proceeds with redacted arguments
Codex CLIPreToolUse~/.codex/hooks.jsonInteractive TUI only. There, a deny rule stops a shell call before it runs. Shell only, and the cause is the host: Codex routes its shell tool to PreToolUse, and a deny on an apply_patch write or an MCP tool call is not imposed by Codex today (upstream openai/codex#27833). Headless codex exec dispatches no hooks at all (upstream openai/codex#26452), so unattended and CI runs are neither enforced nor recorded. The other eight tool families are never delivered to Control Zero, so their absence from the audit log is not evidence they did not happen
Cursor IDEbeforeShellExecution, beforeMCPExecution, beforeReadFile, preToolUse, subagentStart, beforeSubmitPrompt~/.cursor/hooks.jsonBETA -- a deny rule stops the call before it runs, across every tool family, on six deciding events including subagentStart. The install sets failClosed: true, which governs how Cursor treats a hook that fails to run; it is a separate setting from the deny path, and on this surface an empty hook response means proceed. Cannot mask: a mask rule escalates to a deny. The BETA is about our adapter, not that reach: it has not yet been revalidated against a current Cursor build
Cursor CLI (cursor-agent)beforeShellExecution~/.cursor/hooks.json (same install as Cursor IDE)BETA -- a deny rule stops an execute call before it runs. The install sets failClosed: true, which governs how Cursor treats a hook that fails to run rather than the deny path itself; an empty hook response on this surface means proceed. Shell only: beforeShellExecution is the one gate this surface reliably enforces, and the host does not deliver the other eight families to Control Zero. It never receives browser, database, file_read, file_search, file_write, network, prompt or task, so their absence from the audit log is not evidence they did not happen. The BETA is about our adapter: it has not yet been revalidated against a current Cursor build
Kiro CLI (AWS)preToolUse~/.controlzero/policy.yamlGA (direct tool calls gated, and sub-agent spawns from SDK 1.13.12; a sub-agent's own tool calls only when it runs an agent config carrying our hooks) -- a deny rule stops direct tool calls before they run, across every tool family. A sub-agent's own tool calls reach preToolUse and are blocked by a child exit 2 when that sub-agent runs an agent config carrying our hooks; Kiro resolves hooks from the sub-agent's own config and does not inherit the parent's, so a sub-agent spawned without an explicit agent_name runs its own tool calls ungated once it starts -- the spawn itself is still gated, the parent's hook fires either way -- and chat.defaultAgent does not fill that in. Denying the use_subagent spawn by rule needs Python SDK 1.13.12 or later: on any earlier SDK controlzero install kiro never wires use_subagent as a preToolUse matcher, the hook is never invoked for that tool, and a deny rule naming it silently never fires. Check controlzero --version before relying on such a rule. That remaining gap is the host's (kirodotdev/Kiro#7755, open) and is why the adapter still declares coverage partial. use_subagent is the only spawn tool this build exposes -- delegate is disabled, agent_crew is not exposed, and spawn_session is a command value inside a session tool, so there are not four gateable spawn surfaces. Cannot mask: a mask rule escalates to a deny. Driven against kiro-cli 2.18.1
Kiro IDE (AWS)Prompt Submit, File Save.kiro/hooks/COMING SOON -- configured tier: observe. Control Zero neither blocks nor records tool calls on this surface today, and the gap is ours, not the vendor's: current Kiro GA exposes a deterministic PreToolUse gate that carries tool identity and arguments and blocks on exit code 2, and our hooks are not yet wired to it end to end. We say so rather than implying the host cannot do it, because a deterministic gate we are not plugged into is worth exactly zero enforcement, and describing that as a vendor limit would hide work that is on us. Declared coverage lifts when a block is observed end to end, not before. Declaration verified against Kiro IDE >=0.12.333,<1.0.0
AntigravityPreToolUse~/.gemini/config/hooks.jsonBETA -- evaluates at PreToolUse across every tool family and raises a deny. Our posture is fail-closed: empty stdout is read as a deny. Separately, and this is the host: Antigravity treats that deny as advisory in its headless / always-proceed modes, so on those modes the call may still run. Recorded is not stopped. The BETA is ours -- this adapter has not been revalidated against the shipping Antigravity build. See Enforcement Coverage for which Antigravity tools reach the hook. Install with controlzero install antigravity

Install the Cursor, Kiro, and Antigravity hooks with controlzero, the Python CLI -- the controlzero install <agent> syntax below is Python-only. controlzero install kiro installs deterministic enforcement for direct Kiro CLI tool calls: a deny rule stops the call before it runs, across every tool family. Kiro IDE is the separate boundary -- it routes no tool call to a deciding event, so it neither reports nor enforces on tool calls. The older controlzero kiro init remains as a deprecated alias -- see the Kiro integration page.

Quick Start

Install the Control Zero CLI (requires Python 3.9+):

pip install controlzero

Install the hook for your coding assistant:

# Claude Code
controlzero install claude-code

# Gemini CLI
controlzero install gemini-cli

# Codex CLI
controlzero install codex-cli

# Cursor
controlzero install cursor

# Kiro CLI
controlzero install kiro

# Antigravity
controlzero install antigravity

Each command does three things:

  1. Writes a default policy to ~/.controlzero/policy.yaml (ALLOW ALL by default).
  2. Registers the interpreter-pinned hook command in the agent's config file. The installer writes "<python>" -m controlzero hook-check, where <python> is the absolute path to the interpreter that ran install (so the value is machine-specific), instead of a bare controlzero hook-check that would resolve against the agent's PATH at hook time (#1705/#1706).
  3. Confirms installation with a [Control Zero] Installed successfully message.
Migrate a bare controlzero hook-check by re-running install

If an agent's settings still show a bare "command": "controlzero hook-check" (a pre-#1705 install or a hand-edited config), re-run controlzero install <agent> to migrate it to the interpreter-pinned "<python>" -m controlzero hook-check form. controlzero doctor flags a bare command on Claude Code, Gemini CLI, and Codex CLI. Cursor and Kiro CLI were already pinned before #1705; Antigravity intentionally emits the bare form.

The default policy allows every tool call and logs it. Add deny rules to start blocking.

Hosted policy bundles are signed automatically and verified on every tool call. For a fully local policy, enable the same tamper detection by signing it:

controlzero sign-policy

Policy Format

Policies live at ~/.controlzero/policy.yaml. Rules are evaluated top-to-bottom, first match wins.

version: '1'

# Optional: DLP rules scan tool arguments for sensitive data
dlp_rules:
- id: block-credit-cards
pattern: '\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b'
category: pii
action: block
description: Credit card number

- id: block-korean-rrn
pattern: '\d{6}-[1-4]\d{6}'
category: pii
action: block
description: Korean Resident Registration Number

rules:
# Deny the shell on EVERY coding agent with ONE rule. Control Zero normalizes
# each agent's shell tool to the canonical `Bash` (Claude `Bash`, Gemini
# `run_shell_command`, Codex `shell`, Kiro `execute_bash`, Cursor `Shell`, ...),
# so `bash:*` governs them all. Do NOT write a separate rule per agent against
# the raw host tool name -- those names normalize to `Bash` and a raw-name rule
# will not match the canonical action the agent emits.
- id: deny-shell
deny: 'bash:*'
reason: 'Shell access requires approval. Use a file tool instead.'

# Allow everything else (remove for deny-by-default)
- id: allow-everything-else
allow: '*'
reason: 'Default allow.'

Pattern Syntax

  • * matches any tool name.
  • Bash matches only the tool named Bash (case-sensitive).
  • mcp__* matches all MCP tools.
  • mcp__github__* matches all tools from the github MCP server.
  • delete_* matches any tool starting with delete_.

Deny-by-Default

For regulated environments, remove the final allow: '*' rule. The evaluator fails closed: any tool call that does not match an explicit allow rule is denied.

DLP Rules

DLP rules scan tool call arguments (file content, command text, etc.) for sensitive patterns. Each rule specifies:

  • id: Unique identifier
  • pattern: Regex pattern to match
  • category: Classification (pii, financial, proprietary)
  • action: What to do on match (block, detect, mask)
  • description: Human-readable explanation

Built-in patterns are always active and include credit cards, SSNs, emails, API keys, and Korean locale patterns (RRN with mod11 validation, mobile phones, business IDs).

Per-Agent Details

Claude Code (PreToolUse)

The installer registers a PreToolUse hook in ~/.claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "\"/usr/bin/python3\" -m controlzero hook-check",
"timeout": 5000
}
]
}
]
}
}

Claude Code passes a JSON object on stdin with tool_name and tool_input. Common tool names: Bash, Read, Edit, Write, Glob, Grep, WebFetch.

MCP tools appear as mcp__<server>__<tool> (for example, mcp__github__create_issue).

On deny, Claude Code reads the JSON from stdout and displays the [Control Zero] branded reason to the user.

Gemini CLI (BeforeTool)

The installer registers a BeforeTool hook in ~/.gemini/settings.json:

{
"hooks": {
"BeforeTool": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "\"/usr/bin/python3\" -m controlzero hook-check",
"timeout": 5000
}
]
}
]
}
}

Common Gemini CLI tool names: run_shell_command, read_file, write_file, edit_file, glob, search_file_content, web_fetch, google_web_search. MCP tools use the same mcp__<server>__<tool> naming convention.

On deny, Gemini CLI reads the rejection reason from stderr (exit code 2).

Codex CLI (PreToolUse via hooks.json)

The installer creates ~/.codex/hooks.json and enables hooks in ~/.codex/config.toml:

~/.codex/hooks.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "\"/usr/bin/python3\" -m controlzero hook-check",
"timeout": 5000
}
]
}
]
}
}

~/.codex/config.toml (auto-added):

[features]
codex_hooks = true

Common Codex CLI tool names: Bash, shell, apply_patch. On deny, Codex reads the rejection reason from stderr (exit code 2).

Policy signing and tamper detection

Tamper detection catches the case where someone (or a compromised process) modifies your policy file after you wrote it. Without signing, an attacker could replace your strict policy with allow: * and your agent would silently lose all guardrails.

How signing works depends on whether you're a Control Zero user with an account, or running fully local without one. Both options give you tamper protection. The difference is who does the signing.

Hosted Signed automatically by Control Zero

If you're signed up at app.controlzero.ai and using an API key, we sign your policy bundle for you. Every time you save a policy in the dashboard, our backend signs the bundle before the SDK pulls it. You don't run sign-policy. You don't manage signing keys. The hook verifies the signature automatically on every tool call.

This is the default for free accounts and all paid tiers. Just sign up, write your policy in the dashboard, and tamper protection is on.

# Just install the hook with your API key. No signing step needed.
controlzero install claude-code --api-key cz_live_your_key_here

Local Self-sign for fully offline / no-account usage

If you don't want to sign up, or you're running in an air-gapped environment with a local policy file, you can sign the policy yourself. This gives the same tamper guarantee with zero dependency on Control Zero's backend.

# Sign the policy in your current directory
controlzero sign-policy

# Sign a specific file
controlzero sign-policy --policy /path/to/policy.yaml

# Verify a signature without re-signing
controlzero sign-policy --verify-only

The signing key is generated on first use and stored at ~/.controlzero/tamper.key (mode 0600, owner-readable only). Re-run controlzero sign-policy after every legitimate policy edit.

What tamper detection does

Whichever path you take, the hook verifies the signature on every tool call. If verification fails:

  • The audit log records tamper_detected: true
  • A warning appears in stderr: controlzero: policy file tamper detected
  • The configured tamper_behavior decides what happens next: warn (allow, log only), deny (deny this call), deny-all (deny everything until the issue is resolved), or quarantine (lock the agent into a deny-all state until an admin clears it)

You set tamper_behavior in your policy file or in the dashboard.

Built-in DLP coverage

DLP scanning runs against every tool call. The hooks ship with 65 built-in patterns across four categories. All patterns are active by default.

CategoryWhat it catchesExamples
PIIPersonal identifiers across regionsUS SSN, Korean RRN, Japanese My Number, UK NI, German Steuer-ID, French NIR, Indian Aadhaar/PAN, Brazilian CPF, Canadian SIN, Australian TFN, Singapore NRIC, Hong Kong ID, email addresses, phone numbers (US/Korean/Japanese)
FinancialMoney-related identifiersCredit card numbers (with Luhn validation), IBAN, SWIFT/BIC, US routing numbers, Bitcoin and Ethereum addresses
HealthcareHIPAA-relevant identifiersUS NPI, US DEA registration numbers
SecretsAPI keys, tokens, connection stringsAWS, GCP, Azure, OpenAI, Anthropic, HuggingFace, Cohere, GitHub PAT, GitLab PAT, npm, PyPI, Stripe (publishable + secret), SendGrid, Slack tokens + webhooks, Datadog, Sentry, Linear, New Relic, Vault, Doppler, JWT, SSH private keys, PEM certificates, bearer tokens, Postgres / MySQL / MongoDB / Redis / RabbitMQ connection strings, Control Zero API keys

See Locale-aware DLP for the complete pattern list with regex shapes.

Custom DLP rules

Add organization-specific patterns (internal project codes, proprietary identifiers, internal hostnames) via the dashboard's DLP Rules Editor, or directly in your policy file:

dlp_rules:
- id: internal-project-code
pattern: 'PROJ-[A-Z]{3}-\d{6}'
category: custom
action: block
reason: 'Internal project code -- do not paste into external tools'

- id: customer-account-id
pattern: 'CUST_[0-9]{10}'
category: pii
action: mask
reason: 'Customer account IDs are masked in audit logs'

- id: internal-jira-ticket
pattern: '\bACME-\d{4,6}\b'
category: custom
action: detect
reason: 'Track which agents reference internal tickets'

Three actions:

  • block — the call is denied. The tool never executes.
  • mask — on Claude Code and Gemini CLI through the Python SDK, replaces the sensitive value in-place and lets the call proceed with the redacted input. The Node hook path never masks. On Codex CLI, Cursor, Kiro, Antigravity, the gateway, and all other surfaces, a mask match becomes DENY.
  • detect — the call proceeds unchanged. The match is recorded in audit for visibility.

Patterns use Python re syntax (PCRE-compatible subset). Test new patterns from the dashboard before publishing -- the editor runs them against sample text live.

Audit Log

Every governed tool call produces an audit record. The execution status distinguishes did not run from ran and found nothing, so an empty result cannot be mistaken for a blocked or missing execution. The local convenience copy is written to ~/.controlzero/audit.log as JSON lines:

{
"ts": "2026-04-12T07:30:48.429Z",
"decision": "deny",
"tool": "Bash",
"method": "*",
"policy_id": "deny-bash",
"reason": "Shell access requires approval. Use a file tool instead.",
"args_keys": ["command"],
"mode": "local",
"dlp_findings": [],
"tamper_detected": false,
"audit_chain_broken": false
}

View recent audit entries:

# Last 10 entries
tail -10 ~/.controlzero/audit.log

# Filter for denials
grep '"deny"' ~/.controlzero/audit.log

# Filter for DLP blocks
grep 'dlp_findings' ~/.controlzero/audit.log | grep -v '\\[\\]'

# Filter for tamper events
grep '"tamper_detected": true' ~/.controlzero/audit.log

# Watch live
tail -f ~/.controlzero/audit.log

The audit log rotates daily with 30-day retention.

Enterprise Enrollment

Org-wide enrollment: Teams -- Individual hooks work on all tiers. Fleet enrollment, centralized policy management, and audit log aggregation across developers are available in Teams. View pricing

For fleet-wide governance, enroll machines with the Control Zero backend:

controlzero install claude-code --api-key cz_live_your_key_here

Once enrolled:

  • Policies sync from the dashboard to ~/.controlzero/policy.yaml automatically.
  • Audit data ships to the backend for centralized visibility.
  • DLP rules and detector packs from the dashboard are enforced locally: block denies locally. mask redacts only on Claude Code and Gemini CLI through the Python SDK; Kiro CLI and every other operational host deny mask matches, as does the Node SDK. Kiro IDE currently evaluates no mask rules because its installed hooks are inert. detect-mode packs and the always-on built-in catalog are observe-only -- they record a finding to the audit trail but do not block.
  • Fleet status appears on the Devices page in the dashboard.

The hook continues to work locally even when the backend is unreachable. Audit entries queue locally and sync when connectivity resumes.

Verify Installation

# Claude Code: check hook is registered
cat ~/.claude/settings.json | python3 -m json.tool | grep -A6 PreToolUse

# Gemini CLI: check hook is registered
cat ~/.gemini/settings.json | python3 -m json.tool | grep -A8 BeforeTool

# Codex CLI: check hooks.json
cat ~/.codex/hooks.json | python3 -m json.tool | grep -A8 PreToolUse

# Verify policy signature
controlzero sign-policy --verify-only

# Smoke test the evaluator (should show [Control Zero] Allowed)
echo '{"tool_name":"Read","tool_input":{"file":"/tmp/test"}}' \
| controlzero hook-check

# Tail the audit log
tail -f ~/.controlzero/audit.log

Troubleshooting

Hook is not firing. Confirm controlzero is on the PATH for the user running the agent. If you installed in a venv, the agent may not inherit that PATH.

Policy change not taking effect. The hook reloads the policy on every tool call. Check that no ./controlzero.yaml in the current project directory is shadowing the global file. Per-project policies always win over the global one.

Audit log is missing. Check permissions on ~/.controlzero/. The directory must be writable by the user running the agent.

Gemini CLI not blocking denied tools. Verify the BeforeTool hook uses the nested format (with an inner hooks array). The flat format from earlier versions is not recognized by Gemini CLI. Run controlzero install gemini-cli --force to update.

Codex CLI not blocking. Verify ~/.codex/hooks.json exists (not the legacy config.toml wrapper). Run controlzero install codex-cli --force to update. Ensure codex_hooks = true is set in ~/.codex/config.toml.

Tamper detection showing false positives. If you edited the policy legitimately, re-sign it: controlzero sign-policy. If you did not edit it, investigate. Check file permissions and modification timestamps.