Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/__tests__/claude-hook-host.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({ 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));
});
});
18 changes: 15 additions & 3 deletions src/claude-hook-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 => {',
Expand Down
Loading