From 7db81f9c33060e297fe6097cc80a544d56f557dd Mon Sep 17 00:00:00 2001 From: Arnav Singh Date: Fri, 23 Oct 2026 22:17:38 -0700 Subject: [PATCH] fix(hooks): keep the Copilot skip guard text fixed across bundler renames Fixes #1356 Signed-off-by: Arnav Singh --- src/__tests__/claude-hook-host.test.ts | 32 ++++++++++++++++++++++++++ src/claude-hook-host.ts | 18 ++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/claude-hook-host.test.ts diff --git a/src/__tests__/claude-hook-host.test.ts b/src/__tests__/claude-hook-host.test.ts new file mode 100644 index 000000000..e3e36e8d9 --- /dev/null +++ b/src/__tests__/claude-hook-host.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { COPILOT_RUNS_ENTRIES_SOURCE, copilotRunsEntries } from '../claude-hook-host.js'; + +// The shell guard runs COPILOT_RUNS_ENTRIES_SOURCE; the CLI runs copilotRunsEntries. +const guardRuns = new Function(`return ${COPILOT_RUNS_ENTRIES_SOURCE};`)() as (data: unknown) => unknown; + +const hook = (fields: Record) => ({ type: 'command', ...fields }); + +const FILES: Array<[string, unknown]> = [ + ['null', null], + ['a non-object', 'hooks'], + ['version 2', { version: 2, hooks: { sessionStart: [hook({ bash: 'a' })] } }], + ['a version string', { version: '1', hooks: { sessionStart: [hook({ bash: 'a' })] } }], + ['disableAllHooks', { version: 1, disableAllHooks: true, hooks: { sessionStart: [hook({ bash: 'a' })] } }], + ['no hooks', { version: 1 }], + ['hooks that are not an object', { version: 1, hooks: 'a' }], + ['an event that is not an array', { version: 1, hooks: { sessionStart: { bash: 'a' } } }], + ['a hook that is not a command', { version: 1, hooks: { sessionStart: [{ type: 'prompt', bash: 'a' }] } }], + ['a null hook', { version: 1, hooks: { sessionStart: [null, hook({ bash: 'a' })] } }], + ['every command field', { version: 1, hooks: { preToolUse: [hook({ bash: 'b', powershell: 'p', command: 'c', matcher: 'Bash' })] } }], + ['a non-string command', { version: 1, hooks: { sessionStart: [hook({ bash: 1, command: 'c' })] } }], + ['several events', { + version: 1, + hooks: { sessionStart: [hook({ bash: 'a' })], stop: [hook({ command: 'b', matcher: '*' }), hook({ powershell: 'c' })] }, + }], +]; + +describe('COPILOT_RUNS_ENTRIES_SOURCE', () => { + it.each(FILES)('reads %s as copilotRunsEntries does', (_name, file) => { + expect(guardRuns(file)).toEqual(copilotRunsEntries(file)); + }); +}); diff --git a/src/claude-hook-host.ts b/src/claude-hook-host.ts index f38185e50..15346f5bd 100644 --- a/src/claude-hook-host.ts +++ b/src/claude-hook-host.ts @@ -20,8 +20,8 @@ const COPILOT_MARKER = '--tool copilot'; * The [event, matcher, command] entries Copilot runs from a parsed hook file. * Copilot loads only version 1 files and runs none of a file's hooks under * `disableAllHooks`, so any other file yields nothing. Every check of what - * Copilot runs goes through this one predicate. It stays self-contained: - * PROJECT_COPILOT_COPY_CHECK embeds its source in the shell guard. + * Copilot runs goes through this one predicate. The shell guard runs the same + * logic from COPILOT_RUNS_ENTRIES_SOURCE; claude-hook-host.test.ts runs both. */ export function copilotRunsEntries(data: unknown): Array<[string, string | undefined, string]> { const file = data as { version?: unknown; disableAllHooks?: unknown; hooks?: unknown } | null; @@ -33,11 +33,23 @@ export function copilotRunsEntries(data: unknown): Array<[string, string | undef .map((command): [string, string | undefined, string] => [event, entry.matcher, command]))); } +// copilotRunsEntries as plain JavaScript, for the shell guard. A fixed string, +// not copilotRunsEntries.toString(): the bundler may rename the function's +// locals, which would change every Claude team hook command and its hash. +// The text is what earlier releases emitted, so existing hooks stay unchanged. +export const COPILOT_RUNS_ENTRIES_SOURCE = [ + 'function copilotRunsEntries(data) {', + ' const file = data;', + ' if (file?.version !== 1 || file.disableAllHooks || !file.hooks || typeof file.hooks !== "object") return [];', + ' return Object.entries(file.hooks).flatMap(([event, entries]) => !Array.isArray(entries) ? [] : entries.filter((entry) => entry?.type === "command").flatMap((entry) => [entry.bash, entry.powershell, entry.command].filter((command) => typeof command === "string").map((command) => [event, entry.matcher, command])));', + '}', +].join('\n'); + // Shell guards run outside the CLI, so read Copilot's project hook files with Node. // argv[2] is copilotCopyKey of the team hook; a file Copilot runs must hold it. const PROJECT_COPILOT_COPY_CHECK = [ 'const fs = require("node:fs"), path = require("node:path"), crypto = require("node:crypto");', - `const runs = ${copilotRunsEntries.toString()};`, + `const runs = ${COPILOT_RUNS_ENTRIES_SOURCE};`, 'const key = (event, matcher, command) => crypto.createHash("sha256")', '.update(JSON.stringify([event, matcher && matcher !== "*" ? matcher : "", command])).digest("hex");', 'try { process.exit(fs.readdirSync(process.argv[1]).some(name => {',