Coding Assistant Hooks
Supported modes: Hosted Hybrid Local Available in: Free Solo Teams (org-wide enrollment: Teams)
Coding assistant hooks let you govern AI coding tools without changing how your developers work. Install a hook on Claude Code, Gemini CLI, or Codex CLI, and every tool call the agent makes is evaluated against your policy before it executes. No code changes, no proxy, no signup required for local mode.
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 and returns a branded
[Control Zero]reason to the agent. - Logs every decision (allow or deny) to a local audit file with full context.
- 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 | ~/.claude/settings.json | Available |
| Gemini CLI | BeforeTool | ~/.gemini/settings.json | Available |
| Codex CLI | PreToolUse | ~/.codex/hooks.json | Available |
Quick Start
Install the Control Zero CLI (requires Python 3.10+):
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
Each command does three things:
- Writes a default policy to
~/.controlzero/policy.yaml(ALLOW ALL by default). - Registers
controlzero hook-checkas a hook in the agent's config file. - Confirms installation with a
[Control Zero] Installed successfullymessage.
The default policy allows every tool call and logs it. Add deny rules to start blocking.
Optionally sign your policy for tamper detection:
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 destructive shell commands (Claude Code)
- id: deny-bash
deny: 'Bash'
reason: 'Shell access requires approval. Use a file tool instead.'
# Deny shell in Gemini CLI
- id: deny-gemini-shell
deny: 'run_shell_command'
reason: 'Shell commands are blocked by policy.'
# Deny shell in Codex CLI
- id: deny-codex-shell
deny: 'shell'
reason: 'Shell commands are blocked by policy.'
# 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": "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": "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": "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— the matched string is replaced with*****before the call proceeds. The agent gets a redacted version.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 tool call is logged 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 from the dashboard (including locale patterns) are enforced locally.
- 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.