GitHub Copilot Hooks: Checks Around the Agent
Copilot hooks run your own commands when an agent session starts, before and after each tool call, and when the agent stops. Where they work, the file format, three worked hooks, the security rules, and how they differ from Claude Code hooks.
7 min read
GitHub Copilot hooks are shell commands that Copilot runs at fixed points in an agent session: when it starts, when you submit a prompt, before and after every tool call, when the agent stops and when an error occurs. You define them in JSON files under .github/hooks/ in the repository. GitHub documents them for two surfaces, Copilot CLI and Copilot cloud agent, and VS Code runs hooks too, in preview, through whichever agent harness a session uses. A hook is not a request the model may ignore: it runs every time, and a preToolUse hook can refuse a tool call outright.
Where Copilot hooks run
- Copilot CLI: hooks run on your machine, in the same shell as the CLI, and every documented event fires. The CLI reads
.github/hooks/*.jsonin the repository, your own files in~/.copilot/hooks/, ahooksblock in.github/copilot/settings.jsonor~/.copilot/settings.json, hooks shipped in plugins, and a repository’s.claude/settings.json. All of them combine; nothing overrides. - Copilot cloud agent: hooks run inside the short-lived Linux sandbox GitHub creates for each job. Only
.github/hooks/*.jsonfrom the repository exists there, and the file must be on the default branch. There is no user to answer a prompt, so fewer events fire and only thebashcommand is used. - VS Code: Microsoft’s page on agent hooks in VS Code (opens in a new tab) marks the feature as preview and explains that the session target decides which implementation runs. A Copilot session on the Agent Host uses the same implementation as Copilot CLI; the Local harness has its own events and payloads. Check the target before you reuse a hook.
The events
GitHub’s hooks reference (opens in a new tab) lists these. All of them fire under both the CLI and the cloud agent except permissionRequest and notification:
sessionStartandsessionEnd: a session begins or resumes, and ends.sessionStartcan add context for the model.userPromptSubmitted: you send a prompt. Useful for logging; config-file hooks cannot rewrite it.preToolUse: before any tool runs. It can allow, deny or change the arguments. This is the one that enforces rules.postToolUse: after a tool succeeds. It can addadditionalContext, which the model reads after the tool’s output.agentStop: the main agent has finished its turn. Returning"decision": "block"with areasonsends it back for another turn.errorOccurred,subagentStart,subagentStop,preCompactandpostToolUseFailurecover the rest.permissionRequestandnotificationare CLI only.
The file format
A hooks file has "version": 1 and a hooks object keyed by event. Each entry is a command with a bash field, a powershell field for Windows, or a cross-platform command, plus optional cwd (relative to the repository root), env, timeoutSec (30 by default) and, on tool events, a matcher: a regular expression that must match the whole tool name, such as bash or edit. The hook receives the event as JSON on standard input and answers with one JSON object on standard output. Put scripts beside the file and commit both.
Hook one: refuse dangerous commands
The classic preToolUse hook reads the shell command the agent is about to run and denies it if it matches a pattern. GitHub’s Copilot CLI hooks tutorial (opens in a new tab) builds a fuller version with logging and redaction; this is the core.
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./scripts/guard.sh",
"cwd": ".github/hooks",
"timeoutSec": 10
}
]
}
}#!/bin/bash
input=$(cat)
[ "$(echo "$input" | jq -r '.toolName')" = "bash" ] || exit 0
# toolArgs can arrive as a JSON string; parse it either way
cmd=$(echo "$input" | jq -r '.toolArgs | (if type == "string" then fromjson else . end) | .command // empty')
if echo "$cmd" | grep -qE 'git push.*(--force|-f( |$))|rm -rf|curl.*\| *(ba)?sh'; then
jq -nc --arg r "Blocked by repository policy: force-push, rm -rf and piped installs need a person." \
'{permissionDecision: "deny", permissionDecisionReason: $r}'
fi
exit 0- Test it outside Copilot by piping an event in:
echo '{"toolName":"bash","toolArgs":"{\"command\":\"rm -rf build\"}"}' | .github/hooks/scripts/guard.shshould print the deny object. - A
preToolUsecommand hook fails closed: a crash or any non-zero exit denies the tool call. A timeout, though, fails open and the call goes through the normal permission flow. Keep the script fast and do not rely on it alone for anything that must never happen. - On Windows the shell tool is
powershell, so add apowershellentry with an equivalent script; the cloud agent ignores it and usesbash.
Hook two: a nudge after every commit
A postToolUse hook with "matcher": "bash" can check whether the command was a commit and, if so, add a sentence to the model’s context while it still knows what the commit was for. The same script also leaves a marker file, which hook three uses.
{
"version": 1,
"hooks": {
"postToolUse": [
{ "type": "command", "matcher": "bash", "bash": "./scripts/board.sh commit", "cwd": ".github/hooks" },
{ "type": "command", "matcher": ".*fenbs_(comment|update_item)", "bash": "./scripts/board.sh recorded", "cwd": ".github/hooks" }
],
"agentStop": [
{ "type": "command", "bash": "./scripts/board.sh stop", "cwd": ".github/hooks" }
]
}
}#!/bin/bash
input=$(cat)
session=$(echo "$input" | jq -r '.sessionId // "default"')
marker="${TMPDIR:-/tmp}/copilot-unrecorded-$session"
case "$1" in
commit)
cmd=$(echo "$input" | jq -r '.toolArgs | (if type == "string" then fromjson else . end) | .command // empty')
echo "$cmd" | grep -q 'git commit' || exit 0
touch "$marker"
jq -nc '{additionalContext: "You just committed. If this belongs to a fenbs task, comment the commit on it and move it on."}'
;;
recorded)
rm -f "$marker"
;;
stop)
[ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
if [ -f "$marker" ]; then
rm -f "$marker"
jq -nc '{decision: "block", reason: "You committed work and have not recorded it on the fenbs board. Find the task, comment the commit and what changed, and move it on if it is finished."}'
fi
;;
esac
exit 0Hook three: do not stop with unrecorded work
The agentStop branch of the same script is the backstop. If a commit left a marker and no board comment or update cleared it, the hook blocks the stop once and gives the agent a reason, which becomes the prompt for its next turn. Two things keep it from looping. The script checks stop_hook_active, which is true when this turn was already forced to continue, and removes the marker before it answers. And the CLI itself ends the turn after eight blocks in a row.
- The second
postToolUsematcher starts with.*because the model sees an MCP tool under its server’s name as well as its own, so the exact prefix does not matter. - The marker is named after the session id, so two sessions in one repository do not clear each other’s reminders.
- Under the cloud agent the board write needs the board to be reachable from the sandbox. Its firewall allows only GitHub and Copilot hosts by default, so an administrator has to allow the board’s host and the cloud agent needs the server configured.
Security
- Hooks run with your permissions in the CLI. A repository’s hooks load only once you trust the folder, and in
copilot -ponly if it is already trusted orGITHUB_COPILOT_PROMPT_MODE_REPO_HOOKSis set. Read.github/hooks/in any repository before you trust it, including.claude/settings.json, which the CLI also reads. - Treat hook input as untrusted. Tool arguments come from the model; quote them, never paste them into a command unescaped, and redact tokens before you log anything.
- In the cloud agent the sandbox holds
GITHUB_COPILOT_API_TOKEN, every tool is pre-approved, and an"ask"decision is treated as deny. Files a hook writes vanish with the job, so send anything you need to keep to an allowed host. - Administrators can install policy hooks machine-wide, in
/etc/github-copilot/policy.d/orC:\ProgramData\GitHub\Copilot\policy.d\. They load before all others, run in untrusted folders, anddisableAllHooksdoes not switch them off. - HTTP hooks exist as well. For
preToolUsethey must usehttps://, and unlike a command hook, an HTTPpreToolUsehook fails open on a network error, a timeout or a non-2xx answer.
Copilot hooks and Claude Code hooks
The idea is the same and the three hooks above mirror the two in Claude Code hooks that update a board. The differences are in the details. Claude Code keeps hooks under a hooks key in its settings files and names events in PascalCase (PreToolUse, Stop); Copilot uses standalone files with "version": 1 and camelCase names, though it also accepts PascalCase names, which get snake_case payloads and, on PreToolUse, Claude-style matchers. In Claude Code, exit 2 is the blocking signal for every event that can block; in Copilot, any failing exit denies a preToolUse call, while elsewhere exit 2 is a warning. Both cap forced continuations at eight. And Claude Code hooks have handler types Copilot does not, such as calling an MCP tool directly.
Hooks decide what happens around the agent; they do not decide what it may do on your board. On fenbs that is the role and the scopes its connection holds, checked on every call whatever a hook says, and every change is recorded under the assistant’s name. The instruction to use the board in the first place belongs in AGENTS.md or .github/copilot-instructions.md, as in copilot-instructions.md examples. The hook is the backstop for the step that most often goes missing.
Related
Connecting the CLI to a board and allowing its tools: Copilot CLI with MCP. The same board in the editor: GitHub Copilot integration. How the cloud agent takes a task: the GitHub Copilot coding agent.