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.
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:
- Evaluates the tool name against your policy rules (first-match-wins, fail-closed default).
- Scans tool arguments for DLP violations (PII, secrets, proprietary patterns).
- Blocks denied calls before execution and returns a branded
[Control Zero]reason to the agent. - Logs every decision and its execution status to a complete audit trail.
- 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
| Agent | Hook Event | Config Location | Status |
|---|---|---|---|
| Claude Code | PreToolUse, UserPromptSubmit | ~/.claude/settings.json | A 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 CLI | BeforeTool, BeforeAgent | ~/.gemini/settings.json | A 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 CLI | PreToolUse | ~/.codex/hooks.json | Interactive 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 IDE | beforeShellExecution, beforeMCPExecution, beforeReadFile, preToolUse, subagentStart, beforeSubmitPrompt | ~/.cursor/hooks.json | BETA -- 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.yaml | GA (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 |
| Antigravity | PreToolUse | ~/.gemini/config/hooks.json | BETA -- 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:
- Writes a default policy to
~/.controlzero/policy.yaml(ALLOW ALL by default). - 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 raninstall(so the value is machine-specific), instead of a barecontrolzero hook-checkthat would resolve against the agent'sPATHat hook time (#1705/#1706). - Confirms installation with a
[Control Zero] Installed successfullymessage.
controlzero hook-check by re-running installIf 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.Bashmatches only the tool namedBash(case-sensitive).mcp__*matches all MCP tools.mcp__github__*matches all tools from thegithubMCP server.delete_*matches any tool starting withdelete_.
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 identifierpattern: Regex pattern to matchcategory: 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_behaviordecides what happens next:warn(allow, log only),deny(deny this call),deny-all(deny everything until the issue is resolved), orquarantine(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.
| Category | What it catches | Examples |
|---|---|---|
| PII | Personal identifiers across regions | US 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) |
| Financial | Money-related identifiers | Credit card numbers (with Luhn validation), IBAN, SWIFT/BIC, US routing numbers, Bitcoin and Ethereum addresses |
| Healthcare | HIPAA-relevant identifiers | US NPI, US DEA registration numbers |
| Secrets | API keys, tokens, connection strings | AWS, 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.yamlautomatically. - Audit data ships to the backend for centralized visibility.
- DLP rules and detector packs from the dashboard are enforced locally:
blockdenies locally.maskredacts 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.