Skip to main content

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

ToolWhat the extractor readsMethod it produces
databaseThe sql argumentThe most dangerous SQL keyword found in the query (SELECT, INSERT, UPDATE, DELETE, DROP, ALTER, GRANT, and more)
BashThe command argumentThe most dangerous shell command found in the compound command (rm, curl, sudo, git, ls, and so on, basenamed)
httpThe method argumentThe HTTP verb, uppercased (GET, POST, PUT, DELETE)
browserThe action argumentThe browser action string as-is (click, navigate, type)
file_readNothing — the tool name itself is the methodAlways read
file_writeNothing — the tool name itself is the methodAlways 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. WITH covers simple CTEs (WITH cte AS (SELECT ...) SELECT * FROM cte). See Known limitations if your workload uses CTEs that embed DELETE or UPDATE.
  • The explicit deny list is defence in depth. Because default_action is already deny, 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: deny closes everything else. Unfamiliar SQL dialects or new keywords the extractor has never seen (an empty query, a comment-only input) collapse to database:*, 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 is SELECT and the action is database:SELECT. Safe.
  • SELECT 'DROP TABLE users' AS msg — the string literal is stripped, so the action is database:SELECT. Safe.
  • SELECT 'it''s fine' FROM t — the doubled-quote escape is handled and the action is database:SELECT.

Unknown input fails closed, not open

  • Empty SQL ("") or comment-only SQL ("-- nothing here\n") → extractor returns no method → action is database:* → matches no rule → default_action fires.
  • Empty shell command → action is Bash:* → matches no rule → default_action fires.
  • Unknown tool name the extractor has never seen → method is * → the rule unknown_tool:* is what you would need to allow it. Most policies don't list it, so it falls to default_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 as Bash: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 x is tokenized as one statement; its first keyword is WITH, not DELETE. The extractor emits database: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 remove database:WITH from your allow list.
  • Here-docs and process substitution. Constructs like bash <<EOF ... EOF or cat <(...) are grammar the tokenizer does not parse. The action will be Bash:bash or Bash: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:POST rule 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 nameReads from tool_inputOne-line example
databasesql{"sql": "SELECT * FROM users"}database:SELECT
Bashcommand{"command": "rm -rf build"}Bash:rm
httpmethod{"method": "get"}http:GET
browseraction{"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, sqlite
  • Bash: bash, shell, ShellTool, run_shell_command
  • http: fetch, web_fetch, HTTPRequest, request
  • browser: playwright, Puppeteer
  • file_read: read_file, Read, ReadFile
  • file_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 unmatched customtool:* 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.

  • 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 rules and default_action blocks this page reads from.
  • Coding hooks guide — how to install the hook in Claude Code, Gemini CLI, and Codex CLI.