GitHub Copilot preToolUse hooks: how they decide, and the edge cases that matter

Copilot's cloud agent and CLI can run a hook before every tool call. Its rules for exit codes, timeouts and ask are different from Claude Code's. Here they are in one table, with a hook we tested.

The short answer

A preToolUse hook in GitHub Copilot runs before each tool call and can answer allow, deny or ask, with a reason; it can even replace the arguments with modifiedArgs.1 Three rules decide how safe it is in practice:1

  • A hook that errors (non-zero exit other than 2) denies the call. preToolUse is fail-closed on errors.
  • A hook that times out lets the call run. Timeouts are always fail-open, even for preToolUse and admin-deployed hooks. The default timeout is 30 seconds.
  • ask becomes deny in the cloud agent, where no user is available, and in Copilot CLI’s autopilot mode.

So: make the hook fast, make it deny rather than ask when you mean “stop,” and don’t let it depend on anything slow.

Every MCP tool call goes through your rules first. Here a delete that doesn’t say which rows is stopped before it reaches the database. These are the rules deedglass init starts with; the call is made up.

Where hooks live and where they run

Hook files are JSON. Copilot reads them from .github/hooks/*.json in the repository; the CLI also reads ~/.copilot/hooks/ (or $COPILOT_HOME/hooks/).11

Surface Runs hooks where Which files Notes
Copilot CLI Your machine Repository and user hooks All events supported
Copilot cloud agent An ephemeral Linux sandbox .github/hooks/*.json in the cloned repository Only bash or command entries honoured; some events don’t fire; ask is treated as deny

The cloud agent row matters for teams. A hook committed to .github/hooks/ applies to every cloud agent run on that repository, so it’s a team control, reviewed like any other code.1

The full event list includes sessionStart, userPromptSubmitted, preToolUse, postToolUse, postToolUseFailure, agentStop, permissionRequest and others.1 For safety, preToolUse is the one that can stop something before it happens.

What the hook sees and returns

Input fields for preToolUse: sessionId, timestamp, cwd, toolName and toolArgs (camelCase), or the VS Code-compatible session_id, tool_name, tool_input form.1

Output, as JSON on standard output:1

Field Meaning
permissionDecision allow, deny or ask; empty output means default behaviour
permissionDecisionReason Shown to the agent; required for deny
modifiedArgs Replacement arguments for the tool call

The reason is worth writing well. It goes back to the agent, and a clear reason (“Destructive database command. Ask a person to run it.”) tends to make the agent stop and report, instead of trying another spelling of the same command.

The edge cases in one table

From the reference, for preToolUse specifically:1

Situation What happens
Exit 0, no output Default behaviour
Exit 0, JSON with deny Denied, reason shown to the agent
Exit 2 Denied, even if standard output says allow
Any other non-zero exit Denied: “Denied by preToolUse hook (hook errored)”
Timeout (default 30 s, timeoutSec) The call runs (fail-open)
ask in the cloud agent Denied
ask in CLI autopilot Denied; the agent sees the CLI couldn’t ask

Compare with Codex CLI, which marks an ask hook as failed and runs the command, as our Codex sandbox post notes. The same hook script can behave in opposite ways across agents.

A hook we tested

.github/hooks/tool-guard.json, following the reference’s example shape:1

Code Data in JSON, the format software uses to pass records and settings.

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      { "type": "command", "bash": "./scripts/check-tool.sh", "timeoutSec": 10 }
    ]
  }
}

scripts/check-tool.sh:

Code Commands typed in a terminal. The text around it says what happens.

#!/usr/bin/env bash
# preToolUse guard: deny destructive database commands, explain why, never hang.
input="$(cat)"
if printf '%s' "$input" | grep -Eiq '(prisma migrate reset|db push --force-reset|dropdb|DROP (TABLE|DATABASE|SCHEMA)|TRUNCATE)'; then
  printf '%s\n' '{"permissionDecision":"deny","permissionDecisionReason":"Destructive database command. Ask a person to run it."}'
  exit 0
fi
exit 0

We piped three sample inputs in the documented camelCase shape through it:

Code What the screen or file shows, copied as is.

exit=0 out=<empty: default behaviour>
exit=0 out={"permissionDecision":"deny","permissionDecisionReason":"Destructive database command. Ask a person to run it."}
exit=0 out={"permissionDecision":"deny","permissionDecisionReason":"Destructive database command. Ask a person to run it."}

The inputs were npm test, npx prisma migrate reset --force and psql $DATABASE_URL -c "DROP TABLE users". A run on empty input took 0.014 seconds total, far inside a 10-second timeout. The tool name in our samples is a placeholder; the script checks the whole input, so it doesn’t depend on what Copilot calls its shell tool.

Design rules for Copilot hooks

  1. Deny, don’t ask, for anything that must stop. ask turns into deny in the cloud agent and autopilot anyway, and being explicit keeps behaviour the same everywhere.1
  2. Stay fast. A timeout lets the call through. No network calls, no package installs, no npx that might download on first run.
  3. Fail loudly on bugs. A crash denies, which is the safe direction; don’t wrap the script in || true.
  4. Use modifiedArgs sparingly. Rewriting a command silently (adding --dry-run, say) can surprise the agent and the reader of the log. A deny with a reason is easier to reason about.
  5. Review hook changes like CI changes. Anyone who can edit .github/hooks/ can weaken it. Code owners on that directory help.

What a hook can’t do

A hook reads the text of a tool call. A command that runs SQL from a file, or decodes its payload at runtime, carries no dangerous text to match; we showed four such cases in our ALTER TABLE over SSH post. The cloud agent’s sandbox and the credentials you give it set the real limit: a database role that can’t drop tables can’t be talked into it. Our PreToolUse hook glossary entry and hooks across agents cover the rest.

To see which destructive commands an agent on your machine could run without a prompt, paste its config into the check page or run npx deedglass scan (how the grade works); the scan Action runs the same scan on pull requests, including ones that change .github/hooks/. To try tool-call rules in your browser, open the playground.

Questions people ask

What happens if a Copilot preToolUse hook times out?

The tool call runs. Timeouts are fail-open for every event, including preToolUse.1

Does ask work in the Copilot cloud agent?

No. It is treated as deny because no user is available to answer.1

Where do Copilot hooks go?

.github/hooks/*.json in the repository; the CLI also reads ~/.copilot/hooks/.1

Written with AI assistance and checked against our claims register.

The takeaway

A Copilot preToolUse hook that errors denies the call, one that times out lets it run, and ask means deny in the cloud agent. Keep hooks fast; deny.

Read nextCodex CLI sandbox and approval modes: what each one allows, tested on macOS Try it: install in 7 s

Related on this site

In the docs

More posts

Sources

  1. 1GitHub docs: Copilot hooks reference · docs.github.com · checked 26 October 2026

Published 26 October 2026, sources last checked 26 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing