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/*.json in the repository, your own files in ~/.copilot/hooks/, a hooks block in .github/copilot/settings.json or ~/.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/*.json from 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 the bash command 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:

  • sessionStart and sessionEnd: a session begins or resumes, and ends. sessionStart can 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 add additionalContext, which the model reads after the tool’s output.
  • agentStop: the main agent has finished its turn. Returning "decision": "block" with a reason sends it back for another turn.
  • errorOccurred, subagentStart, subagentStop, preCompact and postToolUseFailure cover the rest. permissionRequest and notification are 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.

.github/hooks/policy.json
{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "type": "command",
        "bash": "./scripts/guard.sh",
        "cwd": ".github/hooks",
        "timeoutSec": 10
      }
    ]
  }
}
.github/hooks/scripts/guard.sh
#!/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.sh should print the deny object.
  • A preToolUse command 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 a powershell entry with an equivalent script; the cloud agent ignores it and uses bash.

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.

.github/hooks/board.json
{
  "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" }
    ]
  }
}
.github/hooks/scripts/board.sh
#!/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 0

Hook 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 postToolUse matcher 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 -p only if it is already trusted or GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS is 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/ or C:\ProgramData\GitHub\Copilot\policy.d\. They load before all others, run in untrusted folders, and disableAllHooks does not switch them off.
  • HTTP hooks exist as well. For preToolUse they must use https://, and unlike a command hook, an HTTP preToolUse hook 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.

Questions people ask.

Does GitHub Copilot support hooks?

Yes. GitHub documents hooks for Copilot CLI and Copilot cloud agent, configured in .github/hooks/*.json in the repository, with personal hooks for the CLI in ~/.copilot/hooks. VS Code also runs hooks, in preview, with behaviour that depends on the agent harness the session uses.

Where do Copilot hook files go?

Repository hooks go in .github/hooks as one or more JSON files with version 1. For the cloud agent the file must be on the default branch. Copilot CLI also reads personal hooks from ~/.copilot/hooks and a hooks block in its settings files.

Can a Copilot hook block a command?

Yes. A preToolUse hook that prints a permissionDecision of deny, with a permissionDecisionReason, stops the tool call and tells the agent why. A crash or non-zero exit also denies, but a timeout lets the call through, so keep the hook fast.

Why is my Copilot CLI hook not running?

Check that the file is in .github/hooks, is valid JSON and has version 1, that the script is executable with a shebang line, and that you trusted the folder. The CLI loads hook changes when it starts, so restart it after editing.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.