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 the policy once, but verify enforcement coverage per host and tool path; Codex CLI does not enforce every hooked operation.
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:git -- the extractor emits the most-dangerous recognized first-token per segment, and git is in the danger ordering while npm is not ranked, so git wins and matches allow: Bash:git. A bare npm install (no ranked command) falls back to its first-token basename Bash:npm, which is why both appear in the allow set. 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