fix(block-no-verify): bound the flag scan to the enclosing quote/heredoc line

The scanner locked onto a `git commit` literal that sat inside a quoted
string of an OUTER command (a python heredoc's string, a VAR="..."
assignment, a printf JSON payload), then hasNoVerifyFlag() re-tokenized from
that inner offset with a fresh quote state. When quote parity flipped, the
"segment" ran past the enclosing string and picked up an unrelated later
`bash -n` / `sed -n` / `grep -n` as `commit -n`. Two legitimate commands were
blocked this way in one working session.

The first attempt at this PR (d5c428c) fixed the false positive by SKIPPING
`git` tokens judged to be in "data" context. That was the wrong lever: data
becomes executable the moment it is piped to a shell (`echo '...' | bash`,
`bash <<< '...'`, `cat <<EOF | bash`), and no regex list of shell invocations
is complete (`bash --noprofile -c` slipped). Three independent reviewers
(codex, Greptile, CodeRabbit) demonstrated executing bypasses; that commit is
squashed away here and none of its behaviour survives.

This version keeps every `git` candidate visible and keeps main's blocking
surface intact. A single O(n) preprocessing pass (buildScanBoundaries)
records, for each enclosed character, the closing quote or the current
heredoc-body line end. Subcommand discovery and flag tokenization are capped
at that boundary, so tokens after the enclosing data can never leak into a
candidate's argument list. Also: comment detection is a single mask pass
instead of a repeated prefix scan; heredoc parsing accepts quoted hyphenated
delimiters and CRLF terminators and preserves the `<<` vs `<<-` tab
distinction; quote-assembled words (`g''it`) are recognized.

Verified (all payloads run through the hook as JSON on stdin):
- red-list, 24 executing forms incl. every reviewer-reported bypass -> exit 2
  on main AND here (CRLF heredoc trailer: 0 on main, 2 here);
- green-list, 16 leak-class false positives -> exit 2 on main, 0 here;
- tests/hooks/block-no-verify.test.js 84/84 (main's 25 retained verbatim,
  d5c428c's 8 removed, 59 table-driven red/green cases added);
  tests/hooks/cursor-block-no-verify.test.js 14/14;
- 204,807-byte command with 8,905 non-command `git` literals: 8.5 ms.

Co-Authored-By: Codex (GPT) <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyuZY1LZNuShHiewExekLT
This commit is contained in:
Greg Roy
2026-09-04 22:35:09 -04:00
co-authored by Codex Claude Fable 5.1
parent 4130457d67
commit b3a2d3586b
2 changed files with 503 additions and 76 deletions
+421 -76
View File
@@ -162,11 +162,14 @@ function tokenizeShellWords(input, start = 0, end = input.length) {
return tokens;
}
function findCommandSegmentEnd(input, start) {
/**
* Find the end of a shell command segment without scanning beyond `limit`.
*/
function findCommandSegmentEnd(input, start, limit = input.length) {
let quote = null;
let escaped = false;
for (let i = start; i < input.length; i++) {
for (let i = start; i < limit; i++) {
const char = input.charAt(i);
if (escaped) {
@@ -200,7 +203,7 @@ function findCommandSegmentEnd(input, start) {
}
}
return input.length;
return limit;
}
function commitOptionConsumesNextValue(value) {
@@ -252,24 +255,256 @@ function isCommitNoVerifyShortFlag(value) {
}
/**
* Check if a position in the input is inside a shell comment.
* Precompute the positions that follow a comment marker on their current line.
* This preserves the hook's existing comment heuristic without rescanning a
* potentially long line for every `git` candidate.
*/
function isInComment(input, idx) {
const lineStart = input.lastIndexOf('\n', idx - 1) + 1;
const before = input.slice(lineStart, idx);
for (let i = 0; i < before.length; i++) {
if (before.charAt(i) === '#') {
const prev = i > 0 ? before.charAt(i - 1) : '';
if (prev !== '$' && prev !== '\\') return true;
function buildCommentMask(input) {
const comments = new Uint8Array(input.length);
let afterCommentMarker = false;
let quote = null;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const char = input.charAt(i);
if (char === '\n') {
afterCommentMarker = false;
escaped = false;
continue;
}
comments[i] = afterCommentMarker ? 1 : 0;
if (afterCommentMarker) continue;
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (quote === '"' && char === '\\') {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '#' && (i === 0 || /[\s;&|()]/.test(input.charAt(i - 1)))) {
const previous = i > 0 ? input.charAt(i - 1) : '';
if (previous !== '$' && previous !== '\\') afterCommentMarker = true;
}
}
return false;
return comments;
}
/**
* Find the next 'git' token in the input starting from a position.
* Compute the maximum scan endpoint for characters inside outer quotes and
* heredoc body lines. The hook deliberately keeps every `git` candidate: quoted
* data may later be executed by a shell. Bounds only prevent a candidate's flag
* scan from leaking into unrelated text after its enclosing quote or body line.
*/
function findGit(input, start) {
function buildScanBoundaries(input) {
const boundaries = new Int32Array(input.length);
boundaries.fill(-1);
const pendingHeredocs = [];
let quote = null;
let quoteStart = -1;
let escaped = false;
let comment = false;
for (let i = 0; i < input.length; i++) {
if ((i === 0 || input.charAt(i - 1) === '\n') && pendingHeredocs.length > 0) {
const lineEnd = input.indexOf('\n', i);
const physicalEnd = lineEnd === -1 ? input.length : lineEnd;
const contentEnd = input.charAt(physicalEnd - 1) === '\r' ? physicalEnd - 1 : physicalEnd;
const heredoc = pendingHeredocs[0];
const line = input.slice(i, contentEnd);
const comparableLine = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
if (comparableLine === heredoc.delimiter) {
pendingHeredocs.shift();
} else {
boundaries.fill(contentEnd, i, contentEnd);
}
i = physicalEnd;
continue;
}
const char = input.charAt(i);
if (comment) {
if (char === '\n') comment = false;
continue;
}
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (quote === '"' && char === '\\') {
escaped = true;
continue;
}
if (char === quote) {
boundaries.fill(i, quoteStart + 1, i);
quote = null;
quoteStart = -1;
}
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '"' || char === "'") {
quote = char;
quoteStart = i;
continue;
}
if (char === '#' && (i === 0 || /[\s;&|()]/.test(input.charAt(i - 1)))) {
comment = true;
continue;
}
if (char === '<' && input.charAt(i + 1) === '<' && input.charAt(i + 2) !== '<') {
const heredocMatch = /^<<(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([^ \t\r\n;|&()<>]+))/.exec(input.slice(i));
if (heredocMatch) {
pendingHeredocs.push({
delimiter: heredocMatch[2] || heredocMatch[3] || heredocMatch[4],
stripTabs: heredocMatch[1] === '-',
});
i += heredocMatch[0].length - 1;
}
}
}
if (quote) boundaries.fill(input.length, quoteStart + 1);
return boundaries;
}
/**
* Return the enclosing quote or heredoc-line endpoint for a candidate.
*/
function getScanBoundary(boundaries, idx, fallback) {
const boundary = boundaries[idx];
return boundary >= 0 ? boundary : fallback;
}
/**
* Parse the first non-global-option word after a `git` executable token.
* Git chooses that word as its subcommand, so later words cannot change it.
*/
function findGitSubcommand(input, start, end) {
let value = '';
let tokenStart = -1;
let quote = null;
let escaped = false;
let expectOptionValue = false;
/**
* Classify a completed word, returning a protected Git subcommand if found.
*/
function classifyWord() {
if (tokenStart === -1) return null;
const completed = { value, start: tokenStart };
value = '';
tokenStart = -1;
if (expectOptionValue) {
expectOptionValue = false;
return null;
}
if (completed.value.startsWith('-')) {
if (completed.value === '-c' || completed.value === '-C' ||
completed.value === '--work-tree' || completed.value === '--git-dir' ||
completed.value === '--namespace' || completed.value === '--super-prefix') {
expectOptionValue = true;
}
return null;
}
return {
terminal: true,
command: GIT_COMMANDS_WITH_NO_VERIFY.includes(completed.value) ? completed.value : null,
start: completed.start,
};
}
for (let i = start; i < end; i++) {
const char = input.charAt(i);
if (escaped) {
if (tokenStart === -1) tokenStart = i - 1;
value += char;
escaped = false;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else if (quote === '"' && char === '\\') {
escaped = true;
} else {
if (tokenStart === -1) tokenStart = i;
value += char;
}
continue;
}
if (char === '"' || char === "'") {
if (tokenStart === -1) tokenStart = i;
quote = char;
continue;
}
if (char === '\\') {
if (tokenStart === -1) tokenStart = i;
escaped = true;
continue;
}
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
const completed = classifyWord();
if (completed?.terminal) return completed;
if (char === ';' || char === '|' || char === '&' || char === '\n') return null;
continue;
}
if (tokenStart === -1) tokenStart = i;
value += char;
}
return classifyWord();
}
/**
* Find the next contiguous raw `git` token starting from a position.
*/
function findRawGit(input, start) {
let pos = start;
while (pos < input.length) {
const idx = input.indexOf('git', pos);
@@ -284,88 +519,196 @@ function findGit(input, start) {
}
const before = idx > 0 ? input[idx - 1] : ' ';
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
if (VALID_BEFORE_GIT.includes(before)) return { idx, len, end: idx + len };
pos = idx + 1;
}
return null;
}
/**
* Find a shell word assembled through quoting or escapes that evaluates to
* `git` or `git.exe`. Only words before `end` need inspection because a raw
* candidate at that position is already known to be earlier.
*/
function findAssembledGit(input, start, end) {
let value = '';
let tokenStart = -1;
let quote = null;
let escaped = false;
/**
* Complete the current word and return it when it evaluates to Git.
*/
function completeWord(wordEnd) {
if (tokenStart === -1) return null;
const normalized = value.toLowerCase();
const candidate = normalized === 'git' || normalized === 'git.exe'
? { idx: tokenStart, len: wordEnd - tokenStart, end: wordEnd }
: null;
value = '';
tokenStart = -1;
return candidate;
}
for (let i = start; i < end; i++) {
const char = input.charAt(i);
if (escaped) {
value += char;
escaped = false;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else if (quote === '"' && char === '\\') {
escaped = true;
} else {
value += char;
}
continue;
}
if (char === '"' || char === "'") {
if (tokenStart === -1) tokenStart = i;
quote = char;
continue;
}
if (char === '\\') {
if (tokenStart === -1) tokenStart = i;
escaped = true;
continue;
}
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
const candidate = completeWord(i);
if (candidate) return candidate;
continue;
}
if (tokenStart === -1) tokenStart = i;
value += char;
}
return completeWord(end);
}
/**
* Find the next raw or shell-assembled Git executable token.
*/
function findGit(input, start) {
const rawCandidate = findRawGit(input, start);
const assembledCandidate = findAssembledGit(
input,
start,
rawCandidate ? rawCandidate.idx : input.length
);
return assembledCandidate || rawCandidate;
}
/**
* Normalize the shell word containing `idx`, including adjacent quoted and
* escaped fragments, and return its raw endpoint.
*/
function assembleShellWordContaining(input, idx) {
let wordStart = idx;
while (wordStart > 0 && !/[\s;&|]/.test(input.charAt(wordStart - 1))) {
wordStart--;
}
let value = '';
let quote = null;
let escaped = false;
let wordEnd = input.length;
for (let i = wordStart; i < input.length; i++) {
const char = input.charAt(i);
if (escaped) {
value += char;
escaped = false;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else if (quote === '"' && char === '\\') {
escaped = true;
} else {
value += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
wordEnd = i;
break;
}
value += char;
}
return { value: value.toLowerCase(), end: wordEnd };
}
/**
* Detect which git subcommand (commit, push, etc.) is being invoked.
* Returns { command, offset } where offset is the position right after the
* subcommand keyword, so callers can scope flag checks to only that portion.
*/
function detectGitCommand(input, start = 0) {
function detectGitCommand(input, boundaries, comments, start = 0) {
while (start < input.length) {
const git = findGit(input, start);
if (!git) return null;
if (isInComment(input, git.idx)) {
start = git.idx + git.len;
if (comments[git.idx]) {
start = git.end;
continue;
}
// Find the first matching subcommand token after "git".
// We pick the one closest to "git" so that argument values like
// "git push origin commit" don't misclassify "commit" as the subcommand.
let bestCmd = null;
let bestIdx = Infinity;
const rawGitEnd = git.end;
const enclosingEnd = getScanBoundary(boundaries, git.idx, input.length);
const quotedExecutable = enclosingEnd === rawGitEnd;
const assembledWord = quotedExecutable
? assembleShellWordContaining(input, git.idx)
: null;
const assembledExecutable = assembledWord &&
(assembledWord.value === 'git' || assembledWord.value === 'git.exe');
for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) {
let searchPos = git.idx + git.len;
while (searchPos < input.length) {
const cmdIdx = input.indexOf(cmd, searchPos);
if (cmdIdx === -1) break;
const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' ';
const after = input[cmdIdx + cmd.length] || ' ';
if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; }
if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; }
if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break;
if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; }
// Verify this token is the first non-flag word after "git" — i.e. the
// actual subcommand, not an argument value to a different subcommand.
const gap = input.slice(git.idx + git.len, cmdIdx);
const tokens = gap.trim().split(/\s+/).filter(Boolean);
// Every token before the candidate must be a flag or a flag argument.
// Git global flags like -c take a value argument (e.g. -c key=value).
let onlyFlagsAndArgs = true;
let expectFlagArg = false;
for (const t of tokens) {
if (expectFlagArg) { expectFlagArg = false; continue; }
if (t.startsWith('-')) {
// -c is a git global flag that takes the next token as its argument
if (t === '-c' || t === '-C' || t === '--work-tree' || t === '--git-dir' ||
t === '--namespace' || t === '--super-prefix') {
expectFlagArg = true;
}
continue;
}
onlyFlagsAndArgs = false;
break;
}
if (!onlyFlagsAndArgs) { searchPos = cmdIdx + 1; continue; }
if (cmdIdx < bestIdx) {
bestIdx = cmdIdx;
bestCmd = cmd;
}
break;
}
if (quotedExecutable && !assembledExecutable) {
start = git.end;
continue;
}
if (bestCmd) {
const gitEnd = assembledExecutable ? assembledWord.end : rawGitEnd;
const scanEnd = quotedExecutable ? input.length : enclosingEnd;
const subcommand = findGitSubcommand(input, gitEnd, scanEnd);
if (subcommand?.command) {
return {
command: bestCmd,
offset: bestIdx + bestCmd.length,
command: subcommand.command,
offset: subcommand.start + subcommand.command.length,
gitStart: git.idx,
gitEnd: git.idx + git.len,
commandStart: bestIdx,
gitEnd,
commandStart: subcommand.start,
scanEnd,
};
}
start = git.idx + git.len;
start = git.end;
}
return null;
}
@@ -376,8 +719,8 @@ function detectGitCommand(input, start = 0) {
* right after the detected subcommand keyword) so that flags belonging to
* earlier commands in a chain are not falsely matched.
*/
function hasNoVerifyFlag(input, command, offset) {
const segmentEnd = findCommandSegmentEnd(input, offset);
function hasNoVerifyFlag(input, command, offset, scanEnd) {
const segmentEnd = findCommandSegmentEnd(input, offset, scanEnd);
const tokens = tokenizeShellWords(input, offset, segmentEnd);
let skipNext = false;
@@ -449,10 +792,12 @@ function hasHooksPathOverride(input, detected) {
* Check a command string for git hook bypass attempts.
*/
function checkCommand(input) {
const boundaries = buildScanBoundaries(input);
const comments = buildCommentMask(input);
let start = 0;
while (start < input.length) {
const detected = detectGitCommand(input, start);
const detected = detectGitCommand(input, boundaries, comments, start);
if (!detected) return { blocked: false };
const { command: gitCommand, offset } = detected;
@@ -464,14 +809,14 @@ function checkCommand(input) {
};
}
if (hasNoVerifyFlag(input, gitCommand, offset)) {
if (hasNoVerifyFlag(input, gitCommand, offset, detected.scanEnd)) {
return {
blocked: true,
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
};
}
start = findCommandSegmentEnd(input, offset) + 1;
start = findCommandSegmentEnd(input, offset, detected.scanEnd) + 1;
}
return { blocked: false };
+82
View File
@@ -197,6 +197,88 @@ if (test('still allows -tn (n is the -t template path, not a flag)', () => {
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
// --- Quoted/heredoc candidates: preserve blocking, prevent flag leakage ---
const executingPayloads = [
['retain broad blocking of a quoted git literal', 'echo "git commit -n"'],
['block double-quoted git executable', '"git" commit -n -m x'],
['block single-quoted git executable', "'git' commit -n -m x"],
['block git executable assembled with empty single quotes', "g''it commit -n -m x"],
['block git executable assembled with empty double quotes', 'g""it commit --no-verify -m x'],
['block git executable assembled from quoted prefix', "'g'it commit -n -m x"],
['block git executable assembled from quoted middle', "g'i't commit -n -m x"],
['block git executable assembled with an escape', 'g\\it commit -n -m x'],
['block double-quoted git plus exe suffix', '"git".exe commit -n -m x'],
['block single-quoted git plus exe suffix', "'git'.exe commit -n -m x"],
['block hooksPath after double-quoted git plus exe suffix', '"git".exe -c core.hooksPath=/tmp/no commit -m x'],
['block hooksPath after single-quoted git plus exe suffix', "'git'.exe -c core.hooksPath=/tmp/no commit -m x"],
['block double-quoted git with quote-assembled exe suffix', '"git".e""xe commit -n -m x'],
['block single-quoted git with quote-assembled exe suffix', "'git'.e''xe commit -n -m x"],
['block adjacent quoted git and escaped exe suffix', '"git""\\.exe" commit -n -m x'],
['block quoted git with escaped exe suffix', '"git".\\exe commit -n -m x'],
['block hooksPath after quote-assembled exe suffix', '"git".e""xe -c core.hooksPath=/tmp/no commit -m x'],
['quoted hash does not hide a later commit bypass', 'echo "#"; git commit -n -m x'],
['hash text in quotes does not hide a later commit bypass', 'echo "not # a comment" && git commit --no-verify -m x'],
['word-internal hash does not hide a later commit bypass', 'echo foo#bar; git commit -n -m x'],
['word-internal hash does not hide a later push bypass', 'printf %s foo#bar && git push --no-verify'],
['pipe echo data to bash', "echo 'git commit -n -m x' | bash"],
['pipe printf data to sh', "printf '%s\\n' 'git commit --no-verify -m x' | sh"],
['execute data through xargs and bash -c', "printf '%s\\n' 'git commit -n -m x' | xargs -I CMD bash -c CMD"],
['execute command substitution text through bash', "echo '$(git commit -n -m x)' | bash"],
['execute bash here-string', "bash <<< 'git commit -n -m x'"],
['execute sh here-string', "sh -s <<< 'git commit --no-verify -m x'"],
['block backtick command substitution', 'echo "`git commit -n -m x`"'],
['block substitution after quoted parenthesis', 'echo "$(printf \')\'; git commit -n -m x)"'],
['block substitution after case parenthesis', 'echo "$(case x in x) :;; esac; git commit -n -m x)"'],
['block bash --noprofile -c', "bash --noprofile -c 'git commit -n -m x'"],
['block bash -O extglob -c', "bash -O extglob -c 'git commit -n -m x'"],
['block bash -o pipefail -c', "bash -o pipefail -c 'git commit -n -m x'"],
['block bash -c after option terminator', "bash -c -- 'git commit -n -m x'"],
['block sh -c after option terminator', "sh -c -- 'git commit --no-verify -m x'"],
['block heredoc piped to bash', 'cat <<EOF | bash\ngit commit -n -m x\nEOF'],
['block heredoc piped to sudo bash', 'cat <<EOF | sudo bash\ngit commit --no-verify -m x\nEOF'],
['block heredoc on leading redirection', '<<EOF bash\ngit commit -n -m x\nEOF'],
['block executable command after CRLF heredoc', 'python3 - <<\'PY\'\r\nprint("git commit -n")\r\nPY\r\ngit commit -n -m x'],
['block bash -c double-quoted payload', 'bash -c "git commit -n -m x"'],
['block eval payload', 'eval "git commit --no-verify -m x"'],
['block bash heredoc payload', 'bash <<EOF\ngit commit -n -m x\nEOF'],
['block bypass in a whitespace-separated command sequence', 'git commit -n -m x git commit --no-verify -m x git commit -am x -n git push --no-verify'],
];
for (const [name, command] of executingPayloads) {
if (test(name, () => {
const r = runHook({ tool_input: { command } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
}
const nonLeakingPayloads = [
['python heredoc string with later bash -n', 'python3 - <<\'PY\'\nold="git add -A\\nif ! git diff --cached --quiet; then\\n git commit -q -m \\"vault sync"\nPY\nbash -n vault-sync.sh'],
['assignment string with later bash -n', 'old="git commit -q -m x"; bash -n x.sh'],
['plain commit followed by later-line bash -n', 'git commit -m x\nbash -n s.sh'],
['plain commit followed by grep -n', 'git commit -m x; grep -n foo f.txt'],
['JSON string followed by sed -n', 'printf \'%s\' \'{"cmd":"git commit -q -m \\"x\\""}\' | node x.js; sed -n 1p f'],
['hyphenated Python heredoc delimiter', 'python3 - <<\'PY-SCRIPT\'\nprint("git commit -n")\nPY-SCRIPT\nbash -n x.sh'],
['non-shell heredoc after bash argument', "bash -c 'cat' <<EOF\ngit commit -q -m x\nEOF\nbash -n y.sh"],
['separate commits do not inherit bash -n', 'git commit -m x ; git commit --no-edit ; bash -n x.sh ; git commit -tn'],
['double-quoted literal does not inherit grep -n', 'echo "git commit -q -m x"; grep -n needle file'],
['single-quoted push literal does not inherit later flag', "note='git push'; printf '%s\\n' --no-verify"],
['Python heredoc line does not inherit sed flag', 'python3 <<EOF\nprint("git commit -q -m x")\nEOF\nsed --no-verify file'],
['assignment literal does not inherit grep long flag', "payload='git commit -m x'; grep --no-verify file"],
['printf literal does not inherit bash -n', 'printf \'%s\' "git commit -m x"; bash -n script.sh'],
['assignment literal does not inherit quoted echo -n data', 'old="git commit -q"; echo " -n"'],
['push literal does not inherit quoted printf long flag data', "payload='git push'; printf ' --no-verify'"],
['printf literal does not inherit later quoted echo -n data', 'printf "%s" "git commit -q"; echo " -n"'],
];
for (const [name, command] of nonLeakingPayloads) {
if (test(name, () => {
const r = runHook({ tool_input: { command } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
}
console.log('─'.repeat(50));
console.log(`Passed: ${passed} Failed: ${failed}`);