๐Ÿ“‹ Plan STEP 12 / 15 โฑ 10 min โณ 60 min left

PLAN: Hooks

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:

  • matcher only works on Copilot CLI / Cloud Agent โ€” it matches the two write-side tool names create / edit (see the official hooks reference).
  • The VS Code Copilot extension ignores matcher and 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.
  • bash is executed relative to cwd. 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/ and package-lock.json.
  • permissionDecisionReason and additionalContext return 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 / write tool.
  • The Hook fires โ†’ detects a path under .github/ โ†’ returns permissionDecision: deny.
  • A rejection message like "Repository policy: agents must not modify .github/" appears in the chat.
  • The file is not created.

โฑ ~10 minutes