How to block a coding agent's tool call before it runs
Pre-tool hooks in Claude Code, Cursor and Copilot, the sandbox and approval controls in Codex, and what each one can and cannot stop.
A coding agent's tool call can only be stopped before it runs if something between the model and the tool reads the call and returns a verdict. Today that means a pre-tool hook inside the agent client, an operating-system sandbox that refuses the operation, or a local policy service that mediates the call. An instruction in AGENTS.md that says "never read .env" is not on that list: the model can follow it or not.
What a real pre-execution control needs
Walk one tool call. The model emits a tool name and arguments. The client validates them, then either runs them in its own process (a file read, a shell command) or forwards them to an MCP server over stdio or HTTP. A control that claims to block the call needs four properties:
- It sees the tool name and the arguments before anything executes.
- It can return deny, and deny is final for that call.
- It covers the clients that are actually installed, including the ones nobody approved.
- It writes a record that says which rule fired.
Three substitutes fail these tests. Prompt instructions fail 2 and 4. A deny list of literal command strings fails on argument shape, because rm -rf build, /bin/rm -rf build and sh -c 'rm -rf build' are the same intent with different text. A gateway that only sees routed MCP traffic fails 3, because a stdio server on the laptop never crosses it.
The controls that exist in each client
| Client | Pre-execution mechanism | Where it is configured | How it denies |
|---|---|---|---|
| Claude Code | PreToolUse hook | ~/.claude/settings.json, .claude/settings.json, managed policy settings | exit code 2, or JSON permissionDecision set to deny |
| Cursor | preToolUse, beforeShellExecution, beforeMCPExecution | ~/.cursor/hooks.json, .cursor/hooks.json, /etc/cursor/hooks.json on Linux | exit code 2, or {"permission": "deny"} |
| Codex CLI | no pre-tool hook; sandbox plus approval policy | ~/.codex/config.toml, managed requirements.toml | the sandbox refuses the operation, or the client waits for approval |
| GitHub Copilot CLI | preToolUse hook | .github/hooks/ in the repository | a structured deny from the hook |
Claude Code
The Claude Code hook reference describes three levels of nesting: the event, a matcher group, and one or more handlers. A matcher is an exact tool name or a list separated by |; anything containing other characters is a regular expression. MCP tools appear as mcp__<server>__<tool>, so mcp__github__.* matches every tool from the GitHub server.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__github__.*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pre-tool-policy.sh",
"args": [],
"onFailure": "block"
}
]
}
]
}
}
The handler receives the event as JSON on stdin, including tool_name and tool_input. There are two ways to deny. Exit code 2 blocks the call and passes the hook's stderr to the model:
#!/bin/bash
# .claude/hooks/pre-tool-policy.sh
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
if [[ "$tool" == "Bash" && "$cmd" == *"git push"* ]]; then
echo "Blocked: pushes go through the review agent" >&2
exit 2
fi
exit 0
Or exit 0 and print one JSON object. The same channel can rewrite arguments through updatedInput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}
permissionDecision accepts allow, deny, ask and defer. Deny and ask rules are still evaluated whatever the hook returns, so a hook cannot widen the policy by itself.
Two documented limits matter more. The if filter on a handler is best-effort, and Anthropic's permission guidance is to use the permission system rather than a hook when the allow or deny has to hold. And managed settings are "a client-side control, not a security boundary": on an unmanaged device a user needs no admin rights to bypass them. allowManagedHooksOnly stops user and project hooks from running, but the client binary still enforces it.
Cursor
Cursor's hook reference splits the job across events. preToolUse fires for every tool, beforeShellExecution gets the command string, beforeMCPExecution gets the MCP tool name and parameters, and beforeReadFile covers reads. Hooks live in hooks.json at the user or project level, and enterprise hooks come from the system path or the team dashboard.
{
"version": 1,
"hooks": {
"beforeMCPExecution": [
{
"command": ".cursor/hooks/mcp-policy.sh",
"failClosed": true,
"timeout": 10
}
]
}
}
The handler reads the stdin JSON and prints one object, for example {"permission": "deny", "agent_message": "This MCP tool is not approved."}.
Exit code 2 also denies. Invalid JSON blocks, but a crash, a timeout or a non-zero exit other than 2 fails open unless the hook sets failClosed: true. Set it on anything security relevant. Cloud agents run command hooks from .cursor/hooks.json and defer beforeMCPExecution, sessionStart and sessionEnd, so a hooks-only policy has holes in cloud runs.
Codex
Codex has no user-facing pre-tool hook. It has two layers instead.
approval_policy = "on-request"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = false
Sandbox mode decides where the process can write and whether it can reach the network. read-only allows reads and no writes, workspace-write allows writes inside the workspace plus temporary directories, and the network is off by default. The older modes still allow reads across the disk, so they are not a confidentiality boundary. Approval policy decides when Codex must stop and ask, which by default means an escalation: a write outside the workspace, a network call, or a request to leave the sandbox.
Test the write boundary with codex sandbox macos --log-denials touch ~/sandbox-probe on a Mac, or the same command without the flag on Linux. Expect a refusal. Read isolation is a separate setting, and permission profiles cover it.
Managed requirements.toml can pin allowed_approval_policies and sandbox requirements so a developer cannot loosen them locally. What it cannot do is decide whether a specific MCP tool call is appropriate with the arguments it carries. The sandbox governs where a command can write and connect, not whether the call should happen.
GitHub Copilot CLI
Copilot CLI hooks are repository scoped, under .github/hooks/. Its preToolUse hook receives the tool name and input and can block the call, so it covers sessions in that repository on any machine. It stops applying as soon as someone works outside that checkout.
Where the per-client approach breaks
- N clients, N schemas. Three agents means three hook formats, three test suites, and policy that drifts apart.
- Coverage is a subset. Codex has no pre-tool hook, Cursor's cloud runs skip
beforeMCPExecution, and an agent built on an SDK has none at all. - The hook sees the client's description of the call. If the agent writes a script in one turn and runs it in the next, the hook sees
bash build.sh, not what the script does. - Hooks run on a machine the developer controls. A local administrator can turn them off.
Hooks catch the common case where they exist. They are not a fleet control.
The layer below the hooks
Alyria Beacon closes the gaps that per-client hooks leave. It is a signed, user-space daemon on each endpoint with no kernel driver, and it runs a local policy service that evaluates every MCP call and model call before it executes. The decision happens on the machine, so it works offline and adds no cloud round trip to an agent loop.
Policy is code. Standard rules are Rego; sequence rules describe the order of events rather than a single call, which catches a read followed by a send. The same policy covers Claude Code, Cursor, Codex, Copilot and anything else on the machine, so one rule replaces four hook scripts.
package alyria.lyra
default allow = false
allow {
cap := capability.granted[input.mcp.tool]
input.identity.role == cap.role
input.data.classification <= cap.max_classification
}
deny[msg] {
input.mcp.tool == "shell.exec"
not capability.granted["shell.exec"]
msg := "shell.exec is not brokered for this identity (ASI05)"
}
A denied call produces a decision that names the rule, and the record goes into the audit chain:
{
"decision": "deny",
"plane": "A",
"rule": "alyria.lyra/deny.tool-not-brokered",
"tool": "shell.exec",
"reason": "capability not granted for role software-dev",
"asi": "ASI05"
}
Alyria Beacon deploys read-only and starts in monitor mode, so the first two weeks list what enforcement would have blocked against real traffic. Then you turn rules on one at a time. See Alyria Beacon for the inventory side and Lyra for the policy language.
A rollout that does not stop the team
- Inventory the agents and MCP servers, including stdio servers a gateway cannot see.
- Write five rules, not fifty: brokered tools only, no unpinned MCP servers, no secrets in config, no egress to unapproved hosts, no pushes without review.
- Run them in monitor mode for two weeks, read the false positives, then enforce per rule starting with the ones that never fire in normal work.
- Review denial records monthly and delete rules that never fire.
The test is simple. Take a tool call you would never allow, run it, and check that it did not execute and that a record says why. If a sentence in a prompt is all that stands between the agent and the action, you have a convention, not a control.
Questions this usually raises
Do hooks work offline? Claude Code and Cursor hooks are local commands, so yes, as long as the script needs no network. Alyria Beacon evaluates policy locally by design.
What about an agent with no hook support? That is the case for endpoint mediation. A hook needs the client to cooperate; a policy service that mediates the call does not.