Hooks let you inject arbitrary scripts before and after an agent invokes a tool (PreToolUse / PostToolUse). Returning permissionDecision: deny from PreToolUse lets you stop the agent in its tracks.
12.1 โ Create .github/hooks/hooks.json
Create a new file .github/hooks/hooks.json at the repository root and paste the following as-is:
{
"version": 1,
"hooks": {
"PreToolUse": [
{
"type": "command",
"matcher": "^(create|edit)$",
"bash": "node ./scripts/block.mjs",
"cwd": ".github/hooks",
"timeoutSec": 5
}
]
}
}
Key points:
matcheronly works on Copilot CLI / Cloud Agent โ it matches the two write-side tool namescreate/edit(see the official hooks reference).- The VS Code Copilot extension ignores
matcherand fires the hook on every tool invocation (see the official VS Code docs FAQ). On VS Code the path check inside the script is the only gate. - The matcher regex is auto-wrapped as
^(?:...)$and evaluated as a full match. bashis executed relative tocwd. We'll create the script in the next step.
12.2 โ Create the blocking script
Create a new file .github/hooks/scripts/block.mjs and paste the following:
import { stdin, stdout } from 'node:process';
const rawPayload = await readStdin();
const payload = parseJson(rawPayload);
if (!payload) process.exit(0);
const toolName = String(payload.tool_name ?? payload.toolName ?? 'requested');
const toolInput = payload.tool_input ?? payload.toolArgs ?? {};
const normalizedInput = JSON.stringify(toolInput).replaceAll('\\\\', '/');
const protectedPath = findProtectedPath(normalizedInput);
if (!protectedPath) {
process.exit(0);
}
deny(`Repository policy: agent writes to .github/ or package-lock.json are blocked. The ${toolName} tool referenced ${protectedPath}, which is one of these protected paths.`);
async function readStdin() {
const chunks = [];
for await (const chunk of stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf8').trim();
}
function parseJson(raw) {
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
}
function findProtectedPath(value) {
const protectedPaths = [
{ label: '.github/', pattern: /(^|[\/\s"'`=:([{])(?:\.\/)?\.github(?:\/|$)/i },
{ label: 'package-lock.json', pattern: /(^|[\/\s"'`=:([{])package-lock\.json(?:$|[\/\s"'`),}\]])/i },
];
return protectedPaths.find(({ pattern }) => pattern.test(value))?.label;
}
function deny(reason) {
const additionalContext = 'Protected paths in this repository: .github/ (Copilot config, hooks, workflows) and package-lock.json (deterministic deps). The agent must NOT create, edit, write, delete, rename, or move files that touch these paths. If the user explicitly needs such a change, instruct them to make it manually.';
stdout.write(JSON.stringify({
permissionDecision: 'deny',
permissionDecisionReason: reason,
additionalContext,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
additionalContext,
},
}));
}
Key points:
- The protected targets are just two:
.github/andpackage-lock.json. permissionDecisionReasonandadditionalContextreturn the list of protected paths and the reason back to the agent, so the agent can self-correct on its next turn (it's explicitly told "where it must not touch").- The output covers both the Copilot CLI format (
permissionDecision) and the VS Code format (hookSpecificOutput), so it works in either environment.
12.3 โ Restart Copilot Chat
To load the Hook configuration, close your current Copilot Chat session and open a fresh one (click the ๐ฌ icon โ "+" for a new chat).
12.4 โ Try the agent (and watch it get blocked)
In the new chat, enter the following:
Create a new file called `.github/test.md` with the contents "hello hooks".
Expected behavior:
- The agent attempts to invoke the
create/writetool. - The Hook fires โ detects a path under
.github/โ returnspermissionDecision: deny. - A rejection message like "Repository policy: agents must not modify
.github/" appears in the chat. - The file is not created.
โฑ ~10 minutes