📋 Plan STEP 12 / 15 ⏱ 10分 ⏳ 残り60分

PLAN: Hooks

Hooks は、エージェントがツールを呼び出す前後(PreToolUse / PostToolUse)に任意のスクリプトを差し込める仕組みです。PreToolUsepermissionDecision: deny を返せば、エージェントの動作を 止める ことができます。

12.1 — .github/hooks/hooks.json を作成する

リポジトリのルートに .github/hooks/hooks.json を新規作成し、以下をそのまま貼り付けてください:

{
  "version": 1,
  "hooks": {
    "PreToolUse": [
      {
        "type": "command",
        "matcher": "^(create|edit)$",
        "bash": "node ./scripts/block.mjs",
        "cwd": ".github/hooks",
        "timeoutSec": 5
      }
    ]
  }
}

ポイント:

  • matcherCopilot CLI / Cloud Agent のみで効きます — create / edit の 2 つの書き込み系ツール名にマッチ(公式 hooks リファレンス)。
  • VS Code Copilot 拡張は matcher を無視 し、すべてのツール呼び出しでフックが発火します(VS Code 公式ドキュメント FAQ 参照)。つまり VS Code ではスクリプト側のパスチェックだけがゲートになります。
  • matcher 正規表現は自動的に ^(?:...)$ で囲まれて 完全一致 で評価されます。
  • bashcwd から相対実行されます。スクリプトは次のステップで作成。

12.2 — ブロック用スクリプトを作成する

.github/hooks/scripts/block.mjs を新規作成し、以下を貼り付けてください:

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,
    },
  }));
}

ポイント:

  • 保護対象は .github/package-lock.json の 2 つ。
  • permissionDecisionReasonadditionalContext保護パスの一覧と理由をエージェントに返す ので、エージェントは次のターンで自分で軌道修正できる("どこに触ってはいけないか" を知らされる)。
  • 出力は Copilot CLI 形式permissionDecision)と VS Code 形式hookSpecificOutput)の両方を返すので、どちらの環境でも動きます。

12.3 — Copilot Chat を再起動する

Hook の設定を読み込ませるため、Copilot Chat のセッションを一度閉じて、新しいチャットを開いてください(💬 アイコン → 「+」で新規チャット)。

12.4 — エージェントを試す(わざとブロックさせる)

新しいチャットで以下を入力してください:

.github/test.md という新しいファイルを作って、中身は "hello hooks" にしてください。

期待する挙動:

  • エージェントは create / write ツールを呼び出そうとする。
  • Hook が起動 → .github/ 配下を検出 → permissionDecision: deny を返す。
  • チャット上に「Repository policy: agents must not modify .github/」のような拒否メッセージが表示される。
  • ファイルは作成されない。

⏱ 約10分