Hook action extraction
Supported modes: Hosted Hybrid Local Available in: Free Solo Teams
Why this exists
Your coding agent calls a tool named database with some SQL. You want to allow SELECT and block DROP. The hook extractor turns the SQL into a method name (SELECT, DROP, UPDATE, and so on) so your policy rules can target specific operations. Without it, every call through the coding-agent hook path would collapse to database:* and you could not write read-only rules. The same idea applies to Bash commands, HTTP requests, browser actions, and file reads/writes — the extractor gives each call a stable tool:method shape that your rules can match.
The extractor runs inside the controlzero hook-check CLI that your coding agent calls on every tool use. It is shipped with the Python and Node SDKs and works offline — no network round-trip between the agent and the policy decision.
What gets extracted
| Tool | What the extractor reads | Method it produces |
|---|---|---|
database | The sql argument | The most dangerous SQL keyword found in the query (SELECT, INSERT, UPDATE, DELETE, DROP, ALTER, GRANT, and more) |
Bash | The command argument | The most dangerous shell command found in the compound command (rm, curl, sudo, git, ls, and so on, basenamed) |
http | The method argument | The HTTP verb, uppercased (GET, POST, PUT, DELETE) |
browser | The action argument | The browser action string as-is (click, navigate, type) |
file_read | Nothing — the tool name itself is the method | Always read |
file_write | Nothing — the tool name itself is the method | Always write |
Host agents use different names for the same tool. The extractor normalizes aliases before it runs: Claude Code's Read, Gemini's read_file, and Codex's ReadFile all collapse to file_read:read. PostgreSQL, MySQL, sqlite, and postgres all collapse to database.
For the two "danger-scanning" extractors (database and Bash), the extractor scans every statement or subcommand in the input and picks the worst one. See Security model for why that matters.
Example: read-only database policy
The most common case is "let the AI read data but never change it." Write it once, it works for every coding agent (Claude Code, Gemini CLI, Codex CLI) that has the hook installed.
rules:
- effect: allow
actions:
['database:SELECT', 'database:WITH', 'database:SHOW', 'database:EXPLAIN', 'database:DESCRIBE']
- effect: deny
actions:
[
'database:INSERT',
'database:UPDATE',
'database:DELETE',
'database:DROP',
'database:CREATE',
'database:ALTER',
'database:TRUNCATE',
'database:GRANT',
'database:REVOKE',
]
default_action: deny
Why each rule works:
database:SELECT,database:WITH,database:SHOW,database:EXPLAIN,database:DESCRIBE— the five read-only SQL families.WITHcovers simple CTEs (WITH cte AS (SELECT ...) SELECT * FROM cte). See Known limitations if your workload uses CTEs that embedDELETEorUPDATE.- The explicit deny list is defence in depth. Because
default_actionis alreadydeny, any SQL whose first keyword is not in the allow list would be denied anyway — but naming them makes intent obvious and produces a clearer audit trail. default_action: denycloses everything else. Unfamiliar SQL dialects or new keywords the extractor has never seen (an empty query, a comment-only input) collapse todatabase:*, which no rule matches, so the default fires.
Example: locked-down shell policy
Allow a short list of trusted commands, deny everything else.
rules:
- effect: allow
actions: ['Bash:git', 'Bash:npm', 'Bash:pnpm', 'Bash:ls', 'Bash:cat']
default_action: deny
Every Bash call flows through the extractor. git status && npm install produces the action Bash:npm (the more privileged of the two — npm outranks git in the danger ordering), which matches the allow rule. rm -rf build produces Bash:rm, which hits no rule and falls to default_action: deny.
Security model
The extractor is designed to fail closed. A rule author who writes allow: database:SELECT should not be surprised by a DROP slipping through because the agent happened to send a multi-statement query.
Multi-statement SQL cannot piggyback on an allow rule
SELECT * FROM users; DROP TABLE users;
The extractor scans both statements, ranks DROP above SELECT, and emits database:DROP. Your allow: database:SELECT rule does not match, default_action: deny fires, the call is denied. Safe.
Compound shell commands cannot piggyback on an allow rule
echo ok && rm -rf /
The extractor splits on &&, ranks rm above echo, and emits Bash:rm. Your allow: Bash:echo rule does not match, the call is denied. Safe.
Pipes, ||, semicolons, backticks, and $(...) are all treated as separators — subshells and command substitutions are scanned for their contents too.
Comment-wrapped SQL attacks are neutralized
SQL string literals, line comments (--), and block comments (/* ... */) are stripped before the extractor looks for keywords:
SELECT 1 /* ; DROP TABLE users; */— the block comment is replaced with whitespace, so the first keyword isSELECTand the action isdatabase:SELECT. Safe.SELECT 'DROP TABLE users' AS msg— the string literal is stripped, so the action isdatabase:SELECT. Safe.SELECT 'it''s fine' FROM t— the doubled-quote escape is handled and the action isdatabase:SELECT.
Unknown input fails closed, not open
- Empty SQL (
"") or comment-only SQL ("-- nothing here\n") → extractor returns no method → action isdatabase:*→ matches no rule →default_actionfires. - Empty shell command → action is
Bash:*→ matches no rule →default_actionfires. - Unknown tool name the extractor has never seen → method is
*→ the ruleunknown_tool:*is what you would need to allow it. Most policies don't list it, so it falls todefault_action.
Privilege elevation is ranked above what it wraps
sudo rm -rf /tmp/foo produces Bash:sudo, not Bash:rm. Elevating privilege is policy-relevant on its own, even if the wrapped command is harmless. If you want to allow sudo for specific commands, enforce that at the tool invocation layer — the extractor cannot see past sudo into its argument.
What the extractor does not cover
The extractor is the first layer of defence, not the last. Two-layer defence is the right pattern for hostile or AI-driven callers. Things the extractor deliberately does not catch:
- Interpreter exec payloads. A call like
python -c "<inline-script>"is classified asBash:python. The extractor does not parse the embedded script. Treat interpreter invocations (python,node,ruby,perl,php,bash -c,sh -c) as high-risk in your tool layer, or deny them outright. - CTE-embedded DML. A single statement like
WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM xis tokenized as one statement; its first keyword isWITH, notDELETE. The extractor emitsdatabase:WITH. If your workload uses CTEs and you need strict read-only enforcement, forbid CTE-embedded DML at the tool layer (a real SQL parser can detect it), or removedatabase:WITHfrom your allow list. - Here-docs and process substitution. Constructs like
bash <<EOF ... EOForcat <(...)are grammar the tokenizer does not parse. The action will beBash:bashorBash:cat. If you allow these, restrict the payloads at the tool layer. - URL exfiltration inside benign-looking commands. An interpreter call that opens a socket or posts a file over HTTP is invisible to the extractor — it sees only the interpreter. Restrict interpreters at the tool layer, or route all outbound HTTP through the Gateway so the
http:POSTrule path can enforce it.
The rule of thumb: the extractor surfaces the most dangerous recognized token. Everything else is the caller's responsibility. If that worries you, either keep default_action: deny and whitelist exactly what the AI should do, or pair the hook with the Gateway for a belt-and-braces enforcement.
When the extractor cannot resolve
If the extractor sees a known tool but cannot produce a method — empty SQL, empty shell, a field the tool didn't include — it emits tool:*.
- Be strict: keep
default_action: deny.*actions will not accidentally match an allow rule. - Be permissive: whitelist tool names at the wildcard level, for example
allow: database:*. This allows anything the extractor could not classify, including unparseable SQL. Only do this if you trust the caller.
Supported tools
| Canonical name | Reads from tool_input | One-line example |
|---|---|---|
database | sql | {"sql": "SELECT * FROM users"} → database:SELECT |
Bash | command | {"command": "rm -rf build"} → Bash:rm |
http | method | {"method": "get"} → http:GET |
browser | action | {"action": "click"} → browser:click |
file_read | (nothing — tool name) | Any alias (Read, read_file, ReadFile) → file_read:read |
file_write | (nothing — tool name) | Any alias (Write, write_file, WriteFile, edit_file, Edit) → file_write:write |
Aliases per tool:
database:sql,Database,PostgreSQL,MySQL,postgres,sqliteBash:bash,shell,ShellTool,run_shell_commandhttp:fetch,web_fetch,HTTPRequest,requestbrowser:playwright,Puppeteerfile_read:read_file,Read,ReadFilefile_write:write_file,Write,WriteFile,edit_file,Edit
Unsupported tools
If the host agent sends a tool name the extractor has never seen — your own internal customtool, for example — the action becomes customtool:*. The extractor cannot invent a method it doesn't know how to derive.
You have two options:
- Whitelist the tool at the wildcard level:
allow: customtool:*. Use this only if you trust every possible invocation of that tool. - Keep
default_action: deny: the unmatchedcustomtool:*call will be denied. If you later need a method split for that tool, open a request with its argument shape and we'll add it to the extractor spec.
How to verify
After running a policy against a coding agent, check what actions the extractor actually produced.
From the SDK
controlzero status (shipped with the Python SDK) prints the recent audit entries, including the action string. Run it from any project directory:
$ controlzero status
...
Recent calls
allow database:SELECT by claude-code 2s ago
deny database:DROP by claude-code 1m ago (NO_RULE_MATCH, default_action=deny)
deny Bash:rm by gemini-cli 3m ago (NO_RULE_MATCH, default_action=deny)
The action column is what the extractor emitted. Use it to sanity-check that your rules are matching what you think they are.
From the dashboard
Every hook decision lands in the audit view on the dashboard. The extracted_method column shows the same string the CLI prints. Filter by action to spot calls that are slipping through (or that were denied when they should not have been). See Analytics and audit search.
Related
- Enforcement Behavior — the three knobs (
default_action,default_on_missing,default_on_tamper) that govern what happens around and beyond rule matches. - Policies — how to author the
rulesanddefault_actionblocks this page reads from. - Coding hooks guide — how to install the hook in Claude Code, Gemini CLI, and Codex CLI.