Merge commit 'de480c65fdbbdf8b0b6ffce0f16c0968791c5fbd' into affaan/integrate-reviewed-followups-20260928

This commit is contained in:
affaan-m
2026-09-28 02:27:57 -04:00
3 changed files with 1125 additions and 390 deletions
+271 -372
View File
@@ -15,26 +15,11 @@
'use strict';
const { createBudget, scanShell } = require('./lib/shell-scan');
const MAX_STDIN = 1024 * 1024;
let raw = '';
/**
* Git commands that support the --no-verify flag.
*/
const GIT_COMMANDS_WITH_NO_VERIFY = [
'commit',
'push',
'merge',
'cherry-pick',
'rebase',
'am',
];
/**
* Characters that can appear immediately before 'git' in a command string.
*/
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';
// Git config section and variable names are case-insensitive
// (subsection names are case-sensitive but core.hooksPath has none),
// so we normalize the candidate token to lowercase before matching.
@@ -56,21 +41,10 @@ const COMMIT_OPTIONS_WITH_VALUE = new Set([
'--template',
'--fixup',
'--squash',
'--pathspec-from-file',
'--pathspec-from-file'
]);
const COMMIT_OPTIONS_WITH_INLINE_VALUE = [
'--message=',
'--file=',
'--reuse-message=',
'--reedit-message=',
'--author=',
'--date=',
'--template=',
'--fixup=',
'--squash=',
'--pathspec-from-file=',
];
const COMMIT_OPTIONS_WITH_INLINE_VALUE = ['--message=', '--file=', '--reuse-message=', '--reedit-message=', '--author=', '--date=', '--template=', '--fixup=', '--squash=', '--pathspec-from-file='];
// Short options that take a value. When seen as part of a combined
// short-option token (e.g. -tn), git's parser treats the rest of the
@@ -83,130 +57,12 @@ const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']);
// after them is not the -n flag: `git commit -uno` means --untracked-files=no.
const COMMIT_SHORT_OPTIONS_WITH_OPTIONAL_VALUE = new Set(['u', 'S']);
function tokenizeShellWords(input, start = 0, end = input.length) {
const tokens = [];
let value = '';
let tokenStart = null;
let quote = null;
let escaped = false;
function beginToken(index) {
if (tokenStart === null) {
tokenStart = index;
}
}
function pushToken(index) {
if (tokenStart === null) {
return;
}
tokens.push({
value,
start: tokenStart,
end: index,
});
value = '';
tokenStart = null;
}
for (let i = start; i < end; i++) {
const char = input.charAt(i);
if (escaped) {
beginToken(i - 1);
value += char;
escaped = false;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
continue;
}
if (quote === '"' && char === '\\') {
beginToken(i);
escaped = true;
continue;
}
beginToken(i);
value += char;
continue;
}
if (char === '"' || char === "'") {
beginToken(i);
quote = char;
continue;
}
if (char === '\\') {
beginToken(i);
escaped = true;
continue;
}
if (/\s/.test(char)) {
pushToken(i);
continue;
}
beginToken(i);
value += char;
}
if (escaped) {
value += '\\';
}
pushToken(end);
return tokens;
}
function findCommandSegmentEnd(input, start) {
let quote = null;
let escaped = false;
for (let i = start; i < input.length; i++) {
const char = input.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (quote === '"' && char === '\\') {
escaped = true;
continue;
}
if (char === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === ';' || char === '|' || char === '&' || char === '\n') {
return i;
}
}
return input.length;
}
/**
* Return true when a commit option consumes the following token as its value.
*
* @param {string} value
* @returns {boolean}
*/
function commitOptionConsumesNextValue(value) {
if (isCommitNoVerifyShortFlag(value)) {
return false;
@@ -220,6 +76,12 @@ function commitOptionConsumesNextValue(value) {
return Boolean(shortValueOption && shortValueOption.consumesNextValue);
}
/**
* Return true when a commit option already carries its value in the same token.
*
* @param {string} value
* @returns {boolean}
*/
function commitOptionContainsInlineValue(value) {
if (isCommitNoVerifyShortFlag(value)) {
return false;
@@ -233,6 +95,12 @@ function commitOptionContainsInlineValue(value) {
return Boolean(shortValueOption && shortValueOption.containsInlineValue);
}
/**
* Classify a combined short-option token that includes a value-taking option.
*
* @param {string} value
* @returns {{consumesNextValue: boolean, containsInlineValue: boolean}|null}
*/
function getCommitShortValueOption(value) {
if (!value.startsWith('-') || value.startsWith('--') || value === '-') {
return null;
@@ -243,7 +111,7 @@ function getCommitShortValueOption(value) {
if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(options.charAt(i))) {
return {
consumesNextValue: i === options.length - 1,
containsInlineValue: i < options.length - 1,
containsInlineValue: i < options.length - 1
};
}
}
@@ -251,6 +119,12 @@ function getCommitShortValueOption(value) {
return null;
}
/**
* Return true when a token is commit's `-n` / `--no-verify` short form.
*
* @param {string} value
* @returns {boolean}
*/
function isCommitNoVerifyShortFlag(value) {
if (!value.startsWith('-') || value.startsWith('--') || value === '-') {
return false;
@@ -274,125 +148,6 @@ function isCommitNoVerifyShortFlag(value) {
return false;
}
/**
* Check if a position in the input is inside a shell comment.
*/
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;
}
}
return false;
}
/**
* Find the next 'git' token in the input starting from a position.
*/
function findGit(input, start) {
let pos = start;
while (pos < input.length) {
const idx = input.indexOf('git', pos);
if (idx === -1) return null;
const isExe = input.slice(idx + 3, idx + 7).toLowerCase() === '.exe';
const len = isExe ? 7 : 3;
const after = input[idx + len] || ' ';
if (!/[\s"']/.test(after)) {
pos = idx + 1;
continue;
}
const before = idx > 0 ? input[idx - 1] : ' ';
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
pos = idx + 1;
}
return null;
}
/**
* 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) {
while (start < input.length) {
const git = findGit(input, start);
if (!git) return null;
if (isInComment(input, git.idx)) {
start = git.idx + git.len;
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;
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 (bestCmd) {
return {
command: bestCmd,
offset: bestIdx + bestCmd.length,
gitStart: git.idx,
gitEnd: git.idx + git.len,
commandStart: bestIdx,
};
}
start = git.idx + git.len;
}
return null;
}
/**
* git's option parser accepts any unambiguous prefix of a long option, so
* `--no-veri` and `--no-verif` run as --no-verify. Shorter prefixes such as
@@ -403,131 +158,272 @@ function isNoVerifyLongFlag(value) {
return value.length >= '--no-v'.length && '--no-verify'.startsWith(value);
}
/**
* Check if the input contains a --no-verify flag for a specific git command.
* Only inspects the portion of the input starting at `offset` (the position
* 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);
const tokens = tokenizeShellWords(input, offset, segmentEnd);
let skipNext = false;
const PROTECTED_GIT_COMMANDS = new Set(['commit', 'push', 'merge', 'cherry-pick', 'rebase', 'am']);
const GIT_GLOBAL_VALUES = new Set(['-c', '-C', '--work-tree', '--git-dir', '--namespace', '--super-prefix']);
const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh']);
const DATA_COMMANDS = new Set(['echo', 'printf', 'cat', 'grep', 'head', 'tail', 'wc', 'sort', 'uniq', ':', 'true', 'false']);
const CONTROL_WORDS = new Set(['!', 'if', 'then', 'elif', 'while', 'until', 'do', 'else']);
for (const token of tokens) {
const value = token.value;
if (skipNext) {
skipNext = false;
continue;
}
if (value === '--') {
break;
}
if (command === 'commit') {
if (commitOptionConsumesNextValue(value)) {
skipNext = true;
continue;
}
if (commitOptionContainsInlineValue(value)) {
continue;
}
}
if (isNoVerifyLongFlag(value)) return true;
// For commit, -n is shorthand for --no-verify.
if (command === 'commit' && isCommitNoVerifyShortFlag(value)) {
return true;
}
}
return false;
function basename(value) {
return value.replace(/\\/g, '/').split('/').pop();
}
/**
* Check if the input contains a -c core.hooksPath= override.
*/
function hasHooksPathOverride(input, detected) {
const tokens = tokenizeShellWords(input, detected.gitEnd, detected.commandStart);
for (let i = 0; i < tokens.length; i++) {
const value = tokens[i].value;
// Git config section + variable names are case-insensitive, so a
// bypass attempt like `core.HOOKSPATH=...` or `core.hookspath=...`
// must compare against the lowercased token.
const lowered = value.toLowerCase();
function checkGitWords(words, budget, start = 0) {
let index = start + 1;
let override = false;
for (; index < words.length; index++) {
const value = words[index].value;
budget.spend(value.length + 1);
if (!value.startsWith('-')) break;
if (value === '--') { index++; break; }
if (value === '-c') {
const next = tokens[i + 1] && tokens[i + 1].value;
if (typeof next === 'string' && next.toLowerCase().startsWith(GIT_CONFIG_KEY_PREFIX)) {
return true;
}
i++;
continue;
const setting = words[index + 1]?.value || '';
budget.spend(setting.length + 1);
override ||= setting.toLowerCase().startsWith(GIT_CONFIG_KEY_PREFIX);
} else if (value.toLowerCase().startsWith(`-c${GIT_CONFIG_KEY_PREFIX}`)) override = true;
if (GIT_GLOBAL_VALUES.has(value)) index++;
}
const command = words[index]?.value;
budget.spend((command?.length || 0) + 1);
if (!PROTECTED_GIT_COMMANDS.has(command)) return null;
if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`;
let skipNext = false;
for (index++; index < words.length; index++) {
const value = words[index].value;
budget.spend(value.length + 1);
if (skipNext) { skipNext = false; continue; }
if (value === '--') break;
if (command === 'commit') {
if (commitOptionConsumesNextValue(value)) { skipNext = true; continue; }
if (commitOptionContainsInlineValue(value)) continue;
}
if (lowered.startsWith(`-c${GIT_CONFIG_KEY_PREFIX}`)) {
return true;
if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) {
return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`;
}
}
return false;
return null;
}
/**
* Check a command string for git hook bypass attempts.
*/
function checkCommand(input) {
let start = 0;
while (start < input.length) {
const detected = detectGitCommand(input, start);
if (!detected) return { blocked: false };
const { command: gitCommand, offset } = detected;
if (hasHooksPathOverride(input, detected)) {
return {
blocked: true,
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
};
}
if (hasNoVerifyFlag(input, gitCommand, offset)) {
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;
// Only explicit option grammars remove wrapper operands. Unknown launchers are
// opaque/conservative, never guessed from a name found among data arguments.
function executableWords(words, budget) {
function suffix(start) {
budget.spend(words.length - start);
return words.slice(start);
}
let i = 0;
let assignments = true;
let environmentAssignments = false;
while (i < words.length) {
const token = words[i];
budget.spend(token.value.length + token.raw.length + 1);
if (assignments && /^[A-Za-z_][A-Za-z0-9_]*=/.test(environmentAssignments ? token.value : token.raw)) { i++; continue; }
if (!token.quoted && CONTROL_WORDS.has(token.value)) { i++; continue; }
const name = basename(token.value);
if (name === 'command') {
i++;
while (words[i]?.value.startsWith('-')) {
const flag = words[i++].value;
budget.spend(flag.length + 1);
if (flag === '--') break;
if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return [];
if (!/^-p+$/.test(flag)) return suffix(i - 1);
}
assignments = false; continue;
}
if (name === 'exec') {
i++;
while (words[i]?.value.startsWith('-')) {
const flag = words[i++].value;
budget.spend(flag.length + 1);
if (flag === '--') break;
if (flag === '-a') i++;
else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1);
}
assignments = false; continue;
}
if (name === 'env' || name === 'sudo' || name === 'doas') {
const env = name === 'env';
const values = env
? new Set(['-u', '--unset', '-C', '--chdir'])
: new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']);
const flags = env ? new Set(['-i', '--ignore-environment', '-0', '--null']) : new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']);
i++;
while (words[i]?.value.startsWith('-')) {
const flag = words[i].value;
budget.spend(flag.length + 1);
if (flag === '--') { i++; break; }
if (values.has(flag)) i += 2;
else if (flags.has(flag) || [...values].some(value => value.startsWith('--') ? flag.startsWith(`${value}=`) : flag.startsWith(value) && flag.length > value.length)) i++;
else return suffix(i - 1); // Includes opaque env -S / sudo shell modes.
}
assignments = true; environmentAssignments = true; continue;
}
return suffix(i);
}
return [];
}
function shellRole(words, budget, shell) {
let i = 1;
let stdin = false;
let code = false;
while (i < words.length) {
const option = words[i].value;
budget.spend(option.length + 1);
if (option === '--' || option === '-') { i++; break; }
if (!/^[+-]/.test(option)) break;
if (option === '--rcfile' || option === '--init-file') { i += 2; continue; }
if (option.startsWith('--')) {
if (!['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'].includes(option)) return { kind: 'opaque', stdin: true };
i++; continue;
}
// Bash accepts either sign and consumes a separate operand for each o/O
// even inside a cluster. The command string follows ALL option processing,
// not necessarily the argv word immediately after the first c flag.
let next = i + 1;
for (let j = 1; j < option.length; j++) {
budget.spend();
const flag = option[j];
// Named-option arity is unproved for sh/dash/ksh: keep the invocation
// opaque instead of consuming a code flag as a guessed option operand.
if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return { kind: 'opaque', stdin: true };
if (flag === 'c') code = true;
else if (flag === 's') stdin = true;
else if (shell === 'zsh' && flag === 'o') {
// zsh consumes the rest of this argv word as the option name, or one
// separate word if no suffix exists, then ends this option cluster.
if (j + 1 === option.length && next < words.length) next++;
break;
} else if (shell === 'zsh' && (flag === 'O' || flag === 'b')) {
// These are not Bash's operand grammar; unmodeled zsh modes stay opaque.
return { kind: 'opaque', stdin: true };
} else if (flag === 'o' || flag === 'O') { if (next < words.length) next++; }
else if (!'abefhiklmnprtuvxBCEHPTD'.includes(flag)) return { kind: 'opaque', stdin: true };
}
i = next;
}
if (code) return { kind: 'shell', code: words[i]?.value, stdin: false };
// A script filename and its positional arguments are not shell source text.
return { kind: 'shell', stdin: stdin || i === words.length };
}
function commandRole(words, budget) {
if (!words.length) return { kind: 'data' };
budget.spend(words[0].value.length + 1);
const name = basename(words[0].value);
if (name === 'git' || name === 'git.exe') return { kind: 'git' };
if (SHELLS.has(name)) return shellRole(words, budget, name);
if (name === 'eval') {
for (const word of words) budget.spend(word.value.length + 3);
return { kind: 'shell', code: words.slice(words[1]?.value === '--' ? 2 : 1).map(word => word.value).join(' '), stdin: false };
}
if (DATA_COMMANDS.has(name)) return { kind: 'data' };
return { kind: 'opaque', stdin: true };
}
// Literal producers only. Unmodeled transformations remain conservative rather
// than executing a formatter, interpreter, shell or user-supplied command.
function pipelineSources(command, budget) {
const sources = [];
for (let current = command; current; current = current.pipeFrom) {
budget.spend(current.words.length + 1);
const words = executableWords(current.words, budget);
for (const word of words) budget.spend(word.value.length + 3);
const name = basename(words[0]?.value || '');
if (name === 'echo') sources.push(words.slice(1).filter(word => !/^-[neE]+$/.test(word.value)).map(word => word.value).join(' '));
if (name === 'printf') {
const format = words[1]?.value || '';
if (format !== '-v') sources.push((format === '%s' || format === '%s\\n') ? words.slice(2).map(word => word.value).join('\n') : words.slice(1).map(word => word.value).join(' '));
}
for (const redirect of current.redirects) {
if (redirect.operator === '<<<') sources.push(redirect.word.value);
else if (redirect.operator === '<<' || redirect.operator === '<<-') sources.push(redirect.body);
}
}
return sources;
}
function checkCommand(input) {
const budget = createBudget(input.length);
const pending = [{ text: input, opaque: false }];
function enqueue(text, opaque = false) {
if (!text) return;
budget.spend(text.length + 1);
pending.push({ text, opaque });
}
function inspectOpaque(words, text) {
for (let index = 0; index < words.length; index++) {
const word = words[index];
budget.spend(word.value.length + 1);
if (['git', 'git.exe'].includes(basename(word.value))) {
const reason = checkGitWords(words, budget, index);
if (reason) return reason;
}
if (word.value !== text && /git/.test(word.value) && /[\s'"()]/.test(word.value)) enqueue(word.value, true);
}
return null;
}
try {
while (pending.length) {
const task = pending.pop();
const scan = scanShell(task.text, budget);
for (const text of scan.nested) enqueue(text);
for (const command of scan.commands) {
const words = executableWords(command.words, budget);
const role = commandRole(words, budget);
const reason = task.opaque || role.kind === 'opaque'
? inspectOpaque(command.words, task.text)
: role.kind === 'git' ? checkGitWords(words, budget) : null;
if (reason) return { blocked: true, reason };
if (role.code) enqueue(role.code);
if (role.stdin) {
for (const redirect of command.redirects) {
if (redirect.operator === '<<<') enqueue(redirect.word.value, role.kind === 'opaque');
else if (redirect.operator === '<<' || redirect.operator === '<<-') enqueue(redirect.body, role.kind === 'opaque');
}
if (command.pipeFrom) {
for (const source of pipelineSources(command.pipeFrom, budget)) enqueue(source, role.kind === 'opaque');
}
}
}
}
} catch (error) {
if (!(error instanceof RangeError)) throw error;
return { blocked: true, reason: 'BLOCKED: Shell analysis work budget exceeded; hook-bypass safety could not be established.' };
}
return { blocked: false };
}
/**
* Extract the command string from hook input (JSON or plain text).
*
* @param {string} rawInput
* @returns {string}
*/
function extractCommand(rawInput) {
const trimmed = rawInput.trim();
if (!trimmed.startsWith('{')) return trimmed;
if (!trimmed.startsWith('{')) {
return trimmed;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== 'object' || parsed === null) return trimmed;
if (typeof parsed !== 'object' || parsed === null) {
return trimmed;
}
// Claude Code format: { tool_input: { command: "..." } }
const cmd = parsed.tool_input?.command;
if (typeof cmd === 'string') return cmd;
if (typeof cmd === 'string') {
return cmd;
}
// Generic JSON formats
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
if (typeof parsed[key] === 'string') return parsed[key];
if (typeof parsed[key] === 'string') {
return parsed[key];
}
}
return trimmed;
@@ -538,6 +434,9 @@ function extractCommand(rawInput) {
/**
* Exportable run() for in-process execution via run-with-flags.js.
*
* @param {string} rawInput
* @returns {{exitCode: number, stderr?: string}}
*/
function run(rawInput) {
const command = extractCommand(rawInput);
@@ -546,7 +445,7 @@ function run(rawInput) {
if (result.blocked) {
return {
exitCode: 2,
stderr: result.reason,
stderr: result.reason
};
}
+355
View File
@@ -0,0 +1,355 @@
'use strict';
// Finite literal shell scanner for block-no-verify. This is not an interpreter:
// aliases, generated programs, expansion results and foreign languages remain
// opaque. Every scan/queued region spends one shared input-proportional budget.
function createBudget(length) {
let remaining = 24 * (length + 1) + 4096;
return { spend(amount = 1) {
remaining -= amount;
if (remaining < 0) throw new RangeError('Shell scan work budget exceeded');
} };
}
function continuationEnd(input, index) {
if (input[index] !== '\\') return index;
if (input[index + 1] === '\n') return index + 2;
if (input[index + 1] === '\r' && input[index + 2] === '\n') return index + 3;
return index;
}
// Delimiters undergo quote removal, not command expansion. Keeping this small
// reader shared with substitution matching prevents body punctuation becoming
// shell syntax while locating the enclosing execution region.
function heredocDelimiter(input, start, budget) {
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
const operator = input[start + 2] === '-' ? '<<-' : '<<';
let i = start + operator.length;
while (i < input.length) {
budget.spend();
if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; }
const continued = continuationEnd(input, i);
if (continued === i) break;
i = continued;
}
let value = '';
let quote = null;
let began = false;
let quoted = false;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else value += c;
continue;
}
if (c === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued - 1; continue; }
began = true; quoted = true;
const next = input[i + 1];
if (next === undefined) { value += c; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) value += '\\';
value += next; i++; continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else value += c;
continue;
}
if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; }
if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break;
began = true; value += c;
}
return began ? { operator, word: { value, quoted }, end: i } : null;
}
function heredocLine(input, start, joinContinuations, budget) {
const parts = [];
let i = start;
let fragment = start;
while (i < input.length && input[i] !== '\n') {
budget.spend();
if (joinContinuations && input[i] === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) {
budget.spend(i - fragment + 1);
parts.push(input.slice(fragment, i));
i = continued; fragment = i; continue;
}
// An escaped backslash cannot itself quote the following newline. Keep
// the pair unchanged; only the unpaired final slash joins physical lines.
if (i + 1 < input.length) { i += 2; continue; }
}
i++;
}
budget.spend(3 * (i - start + 1));
parts.push(input.slice(fragment, i));
const newline = i < input.length;
return { text: parts.join(''), newline, end: newline ? i + 1 : i };
}
function heredocBody(input, start, redirect, budget) {
const lines = [];
let length = 0;
let i = start;
while (i < input.length) {
// Bash removes unquoted backslash-newline before testing the ending
// delimiter. Quoted delimiters retain physical lines. The original end
// offset is kept separately so the next actual command is never swallowed.
const line = heredocLine(input, i, !redirect.word.quoted, budget);
budget.spend(2 * (line.text.length + 1));
const text = redirect.operator === '<<-' ? line.text.replace(/^\t+/, '') : line.text;
i = line.end;
if (text.replace(/\r$/, '') === redirect.word.value) break;
lines.push(text);
length += text.length;
if (line.newline) { lines.push('\n'); length++; }
}
budget.spend(length + 1);
return { body: lines.join(''), end: i };
}
function legacyRegion(input, start, budget) {
const decoded = [];
let i = start + 1;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (c === '`') break;
if (c === '\\' && '$`\\\n'.includes(input[i + 1] || '\u0000')) {
// One old-style substitution layer only. Escapes outside an executable
// backtick region are still handled by the outer lexer as literal data.
const next = input[++i];
if (next !== '\n') decoded.push(next);
} else decoded.push(c);
}
budget.spend(decoded.length + 1);
return { text: decoded.join(''), end: i < input.length ? i + 1 : i };
}
// Locate a nested execution region without evaluating any supplied text.
// Balanced substitutions have independent quote state; malformed regions extend
// to EOF and remain conservatively inspectable instead of silently disappearing.
function executionRegion(input, start, budget) {
if (input[start] === '`') return legacyRegion(input, start, budget);
const from = start + 2;
const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] });
const stack = [frame()];
function endWord(state) {
if (!state.word) return;
const phase = state.cases[state.cases.length - 1];
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
state.commandStart = false;
state.word = ''; state.quotedWord = false;
}
for (let i = from; i < input.length; i++) {
budget.spend();
const state = stack[stack.length - 1];
const c = input[i];
if (state.quote === "'") {
if (c === "'") state.quote = null;
continue;
}
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; }
if (c === '$' && input[i + 1] === '(') {
state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue;
}
if (c === '`') {
const region = legacyRegion(input, i, budget);
state.word += '`'; state.quotedWord = true; i = region.end - 1; continue;
}
if (state.quote === '"') {
if (c === '"') state.quote = null;
continue;
}
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; }
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
i--; // Process the newline, including any pending heredoc bodies.
continue;
}
if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; }
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
endWord(state);
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; }
}
if (c === '\n' && state.heredocs.length) {
endWord(state);
let next = i + 1;
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
state.heredocs.length = 0;
state.commandStart = true; i = next - 1; continue;
}
if (/[\s;|&()]/.test(c)) endWord(state);
else state.word += c;
const phase = state.cases[state.cases.length - 1];
// A case pattern's closing ')' is not the end of $(...). Only literal
// keyword positions affect this state; quoted or echo operands do not.
if (c === ')' && phase === 'pattern') {
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue;
}
if (c === '(' && phase === 'pattern') continue;
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue;
}
if (/[;|&\n]/.test(c)) state.commandStart = true;
if (c === '(') state.depth++;
if (c === ')') {
state.depth--;
if (state.depth === 0) {
stack.pop();
if (stack.length === 0) {
budget.spend(i - from + 1);
return { text: input.slice(from, i), end: i + 1 };
}
}
}
}
budget.spend(input.length - from + 1);
return { text: input.slice(from), end: input.length };
}
function hasExpansion(input, index, processSubstitution = false) {
return input[index] === '`' || (input[index] === '$' && input[index + 1] === '(') ||
(processSubstitution && '<>'.includes(input[index]) && input[index + 1] === '(');
}
// Unquoted heredoc bodies expand even inside quote characters in the body.
// Backslash still protects $, ` and backslash; quoted delimiters skip this pass.
function scanExpansions(input, budget) {
const nested = [];
for (let i = 0; i < input.length;) {
budget.spend();
if (input[i] === '\\' && /[$`\\\r\n]/.test(input[i + 1] || '')) {
const continued = continuationEnd(input, i);
i = continued !== i ? continued : i + 2;
continue;
}
if (hasExpansion(input, i)) {
const region = executionRegion(input, i, budget);
nested.push(region.text); i = region.end;
} else i++;
}
return nested;
}
function scanShell(input, budget) {
const commands = [];
const nested = [];
const pendingHeredocs = [];
let current = { words: [], redirects: [], pipeFrom: null };
let word = null;
let quote = null;
let pendingRedirect = null;
let i = 0;
function begin() {
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true };
}
function flushWord() {
if (!word) return;
word.end = i;
word.raw = input.slice(word.start, i);
if (pendingRedirect) {
const redirect = { operator: pendingRedirect, word, body: '' };
current.redirects.push(redirect);
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push(redirect);
pendingRedirect = null;
} else current.words.push(word);
word = null;
}
function flushCommand(pipe = false) {
flushWord();
const previous = current;
if (previous.words.length || previous.redirects.length) commands.push(previous);
current = { words: [], redirects: [], pipeFrom: pipe ? previous : null };
pendingRedirect = null;
}
function consumeHeredocs() {
for (const redirect of pendingHeredocs) {
const region = heredocBody(input, i, redirect, budget);
redirect.body = region.body;
i = region.end;
if (!redirect.word.quoted) nested.push(...scanExpansions(redirect.body, budget));
}
pendingHeredocs.length = 0;
}
while (i < input.length) {
budget.spend();
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else word.value += c;
i++; continue;
}
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued; continue; }
if (c === '\\') {
begin(); word.quoted = true;
const next = input[i + 1];
if (next === undefined) { word.value += c; i++; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
word.value += next; i += 2; continue;
}
if (hasExpansion(input, i, quote === null)) {
begin(); word.literal = false;
const region = executionRegion(input, i, budget);
nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else word.value += c;
i++; continue;
}
if (c === '$' && input[i + 1] === "'") {
// Literal ANSI-C words without escape interpretation; escaped/generated
// names are not claimed to be a complete expansion implementation.
begin(); word.quoted = true; quote = "'"; i += 2; continue;
}
if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; }
if (c === '#' && !word) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
continue;
}
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
const pipe = c === '|' && input[i + 1] !== '|';
flushCommand(pipe);
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
if (c === '\n') consumeHeredocs();
continue;
}
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) {
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
current.redirects.push(redirect); pendingHeredocs.push(redirect);
i = delimiter.end; pendingRedirect = null; continue;
}
}
if (c === '<' || c === '>') {
// An immediately adjacent numeric word is a descriptor, not an argv word.
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
let operator = c;
if (input[i + 1] === c) operator += c;
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
else if (input[i + 1] === '&') operator += '&';
pendingRedirect = operator; i += operator.length; continue;
}
if (/\s/.test(c)) { flushWord(); i++; continue; }
begin(); word.value += c; i++;
}
flushCommand();
return { commands, nested };
}
module.exports = { createBudget, scanShell, scanExpansions };
+499 -18
View File
@@ -4,6 +4,10 @@
const assert = require('assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
const vm = require('vm');
const hook = require('../../scripts/hooks/block-no-verify');
const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
@@ -20,25 +24,10 @@ function test(name, fn) {
}
}
function runHook(input, env = {}) {
function runHook(input) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
const result = hook.run(rawInput);
return { code: result.exitCode, stdout: result.stdout || '', stderr: result.stderr || '' };
}
let passed = 0;
@@ -219,6 +208,103 @@ 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 = [
// Quoted heredoc delimiter disables shell expansion but Python still consumes code; unsupported interpreter language remains conservative on a literal bypass phrase.
['hyphenated Python heredoc delimiter', 'python3 - <<\'PY-SCRIPT\'\nprint("git commit -n")\nPY-SCRIPT\nbash -n x.sh'],
['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 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 second command in a chain', 'git commit -m ok; git commit --no-verify -m x'],
['block third command in a chain', 'git add -A && git commit -m ok && git push --no-verify'],
['block combined short flag after a clean command', 'git commit -m ok; git commit -am x -n'],
['block bypass in a whitespace-separated command sequence', 'git commit -m ok git push --no-verify'],
['block ANSI-C quoted git executable', "$'git' commit -n -m x"],
['block ANSI-C quoted git with long flag', "$'git' commit --no-verify -m x"],
['block line-continued commit bypass', 'git commit \\\n--no-verify -m x'],
['block heredoc line-continued commit bypass', 'cat <<EOF | bash\ngit commit \\\n--no-verify -m x\nEOF'],
];
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 = [
// Inside double quotes a backslash before dot remains literal, so decoded executable is git\.exe, not git.exe.
['allows the distinct executable with a literal escaped dot', '"git""\\.exe" commit -n -m x'],
// A complete echo operand is data; accepted role repair intentionally corrects the authored broad-blocking expectation.
['allows quoted echo data (corrected author expectation)', 'echo "git commit -n"'],
['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'],
['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"'],
['quoted git executable does not inherit later grep -n', '"git" status; grep -n needle file'],
['assembled git executable does not inherit later bash -n', "g''it status && bash -n script.sh"],
['quoted git executable does not inherit quoted echo -n data', "'git' status; echo \" -n\""],
['quoted git commit does not inherit later bash -n', '"git" commit -m x; bash -n y.sh'],
['ANSI-C quoted status does not inherit later grep -n', "$'git' status; grep -n needle file"],
['heredoc python string line ending in backslash does not inherit later bash -n', 'python3 - <<\'PY\'\nprint("git commit -q \\\n")\nPY\nbash -n x.sh'],
];
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++;
}
// --- Optional stuck values (-u, -S) and long-option prefixes ---
if (test('allows -uno (n is the -u untracked-files mode, not a flag)', () => {
@@ -251,6 +337,401 @@ if (test('allows --no-verbose (not a prefix of --no-verify)', () => {
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
// Finite literal role regressions: supplied command strings are never executed.
for (const command of [
"git commit -m \"$(git push --no-verify)\"",
"git commit -m \"$(git -c core.hooksPath=/dev/null push)\"",
"git commit --message=\"$(git push --no-veri)\"",
"git commit -m \"`git push --no-verify`\"",
"echo '#'; git push --no-verify",
"'bash' -c 'git push --no-verify'",
"printf '%s' 'git commit --no-verify'; git push --no-verify",
"printf '%s' 'git push --no-verify' | sh",
"printf '%s' 'git push --no-verify' | sudo -u root bash",
"git commit -m \"$(printf '%s' 'git push --no-verify' | sh)\"",
"echo $(echo $(git push --no-verify))",
"cat <(git push --no-verify)",
"cat <<EOF\n'$(git push --no-verify)'\nEOF",
"cat <<EOF\n`git push --no-verify`\nEOF",
"bash <<'EOF'\ngit push --no-verify\nEOF",
"cat <<'EOF' | bash\ngit push --no-verify\nEOF",
"cat <<-EOF | sh\n\tgit push --no-verify\n\tEOF",
"cat <<A <<B\nsafe\nA\n$(git push --no-verify)\nB",
"A=$(git push --no-verify) echo safe",
"A=x command -- git push --no-verify",
"command -p git commit --no-verif",
"exec -a git /usr/bin/git commit --no-verify",
"env -u HOME git push --no-verify",
"sudo -u git git push --no-verify",
"eval 'git' 'push' '--no-verify'",
"bash -lc 'git push --no-verify' 'data'",
"sh -c -- 'git commit --no-verify' arg0",
"(git commit -m safe; git push --no-verify)",
"if true; then git push --no-verify; fi",
"echo safe # git commit -m safe\ngit push --no-verify",
"g\\\nit push --no-verify",
"git com''mit --no-verify",
"git commit --no-veri # actual option",
"git push '--no-verify'",
"custom-wrapper 'git push --no-verify'",
"python -c 'os.system(\"git push --no-verify\")'",
"python3 <<'PY'\nprint(\"git commit -n\")\nPY"
]) {
if (test(`literal denied: ${JSON.stringify(command)}`, () => {
const result = runHook({ tool_input: { command } });
assert.strictEqual(result.code, 2, result.stderr);
assert.deepStrictEqual(runHook({ tool_input: { command } }), result, 'Second call must not inherit lexical state');
})) passed++; else failed++;
}
for (const command of [
"printf '%s' 'git commit --no-verify'",
"printf '%s' eval 'git commit --no-verify'",
"echo 'git commit' --no-verify",
"bash -c 'echo ok' 'git push --no-verify'",
"'bash' -c 'printf %s safe' 'git push --no-verify'",
"git commit -m --no-verify",
"git commit -Skeyn -m x",
"git commit -- --no-verify",
"git push '--no-verify;literal'",
"git push '--no-verify)literal'",
"git commit -m '$(git push --no-verify)'",
"printf '%s' '$(git push --no-verify)'",
"echo \"\\$(git push --no-verify)\"",
"echo \"\\`git push --no-verify\\`\"",
"cat <<'EOF'\n$(git push --no-verify)\nEOF",
"cat <<\\EOF\ngit push --no-verify\nEOF",
"cat <<EOF\ngit push --no-verify\nEOF",
"cat <<EOF\n\\$(git push --no-verify)\nEOF",
"cat <<'EOF'\ntext\nEOF\ngit commit -m safe",
"echo 'git commit --no-verify' | cat",
"printf '%s' 'git push --no-verify' | grep git",
"cat <<'EOF' | cat\ngit push --no-verify\nEOF",
"bash -c cat <<'EOF'\ngit push --no-verify\nEOF",
"echo '# git push --no-verify'",
"echo safe # git push --no-verify",
"git commit -m safe # --no-verify",
"payload='git push --no-verify'",
"command -v git push --no-verify",
"command -pV git commit --no-verify",
"exec -a git echo 'push --no-verify'",
"env -u git echo 'git push --no-verify'",
"sudo -u git echo 'git push --no-verify'",
"git commit -m safe > --no-verify",
"git commit -m safe 2> --no-verify",
"printf '%s' 'git push --no-verify'; git push origin main",
"git -C '/tmp/git push --no-verify' status",
"echo 'git commit --no-verify' > log",
"bash script.sh 'git push --no-verify'"
]) {
if (test(`literal allowed: ${JSON.stringify(command)}`, () => {
const result = runHook({ tool_input: { command } });
assert.strictEqual(result.code, 0, result.stderr);
assert.deepStrictEqual(runHook({ tool_input: { command } }), result, 'Second call must not inherit lexical state');
})) passed++; else failed++;
}
if (test('bounded wide quoted data remains data', () => {
assert.strictEqual(runHook(`printf '%s' ${"'git push --no-verify' ".repeat(2000)}`).code, 0);
})) passed++; else failed++;
if (test('deep nested substitutions fail closed within the work budget', () => {
assert.strictEqual(runHook('echo ' + '$('.repeat(120) + 'git push --no-verify' + ')'.repeat(120)).code, 2);
})) passed++; else failed++;
for (const command of [
'echo note{git push --no-verify}',
'printf %s note{git push --no-verify}',
'echo "$(printf %s case)"',
"echo 'case x in x) git push --no-verify;; esac'",
]) {
if (test(`literal role control: ${JSON.stringify(command)}`, () => {
assert.strictEqual(runHook(command).code, 0);
})) passed++; else failed++;
}
if (test('moderate nested execution identifies the actual Git bypass', () => {
const result = runHook('echo ' + '$('.repeat(8) + 'git push --no-verify' + ')'.repeat(8));
assert.strictEqual(result.code, 2);
assert.match(result.stderr, /git push/);
})) passed++; else failed++;
if (test('escaped backtick inside substitution does not hide a later command', () => {
const result = runHook('echo "`printf %s \\`; git push --no-verify`"');
assert.strictEqual(result.code, 2);
assert.match(result.stderr, /git push/);
})) passed++; else failed++;
// Review regressions: literal option roles and nested execution boundaries.
for (const [expected, commands] of [
[2, [
"bash +x -c 'git push --no-verify'",
"bash +o posix -c 'git push --no-verify'",
"bash +o errexit -c 'git push --no-verify'",
"bash +O extglob -c 'git commit -n'",
"bash +xo posix -c 'git push --no-verify'",
"bash +oO posix extglob -c 'git push --no-verify'",
"bash -c +x 'git push --no-verify'",
"bash -co posix 'git push --no-verify'",
"bash +c 'git push --no-verify'",
"bash +x -c -- 'git push --no-verify'",
"bash +x -c - 'git push --no-verify'",
"bash +unknown -c 'git push --no-verify'",
"echo \"$(cat <<'EOF'\n)\nEOF\ngit push --no-verify\n)\"",
"echo \"$(cat <<EOF\n)\nEOF\ngit push --no-verify\n)\"",
"echo \"$(cat <<')'\ntext\n)\ngit push --no-verify\n)\"",
"echo \"$(cat <<E'OF'\n)\nEOF\ngit push --no-verify\n)\"",
"echo \"$(cat <<\\EOF\n)\nEOF\ngit push --no-verify\n)\"",
"echo \"$(cat <<-EOF\n\t)\n\tEOF\ngit push --no-verify\n)\"",
"echo \"$(cat <<A <<'B'\n)\nA\n)\nB\ngit push --no-verify\n)\"",
"echo \"$(cat <<'EOF' # delimiter is pending\n)\nEOF\ngit push --no-verify\n)\"",
"echo \"$(echo $(cat <<'EOF'\n)\nEOF\ngit push --no-verify\n))\"",
"echo \"$(cat <<EOF\n)\n$(git push --no-verify)\nEOF\n)\"",
"echo \"`echo \\`git push --no-verify\\``\"",
"echo \"`echo \\$(git push --no-verify)`\"",
"echo \"`git push --no-verify`\"",
"echo \"$(echo $(git push --no-verify))\"",
]],
[0, [
"bash +x -c 'echo safe' 'git push --no-verify'",
"bash +o posix -c 'echo safe' 'git push --no-verify'",
"bash +O extglob -c 'echo safe' 'git push --no-verify'",
"bash +oO posix extglob -c 'echo safe' 'git push --no-verify'",
"bash -co posix 'echo safe' 'git push --no-verify'",
"bash -c +x 'echo safe' 'git push --no-verify'",
"bash +x script.sh 'git push --no-verify'",
"bash -- +x -c 'git push --no-verify'",
"echo \"$(cat <<'EOF'\n)\ngit push --no-verify\nEOF\n)\"",
"echo \"$(cat <<EOF\n)\ngit push --no-verify\nEOF\n)\"",
"echo \"$(cat <<')'\ngit push --no-verify\n)\n)\"",
"echo \"$(cat <<E'OF'\n)\n$(git push --no-verify)\nEOF\n)\"",
"echo \"$(cat <<-EOF\n\t)\n\tgit push --no-verify\n\tEOF\n)\"",
"echo \"$(cat <<A <<'B'\n)\nA\ngit push --no-verify\n)\nB\n)\"",
"echo \"\\`git push --no-verify\\`\"",
"echo '`echo \\`git push --no-verify\\``'",
"echo \"`echo 'git push --no-verify'`\"",
"printf '%s' 'git push --no-verify'",
]],
]) {
for (const command of commands) {
if (test(`review boundary ${expected}: ${JSON.stringify(command)}`, () => {
const result = runHook(command);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git (push|commit)/, 'The literal bypass, not budget exhaustion, must be identified');
})) passed++; else failed++;
}
}
// Nearby delimiter roles use the same lexer and must not become heredocs.
for (const [expected, command] of [
[2, "echo \"$(cat <<\\\n EOF\n)\nEOF\ngit push --no-verify\n)\""],
[0, "echo \"$(cat <<\\\n EOF\n)\ngit push --no-verify\nEOF\n)\""],
[2, "echo \"$(cat <<<EOF\ngit push --no-verify\n)\""],
[0, "echo \"$(cat <<<EOF\nprintf '%s' 'git push --no-verify'\n)\""],
[2, "echo \"$(cat <<\"EOF\"\n)\nEOF\ngit push --no-verify\n)\""],
[0, "echo \"$(cat <<\"EOF\"\n)\n$(git push --no-verify)\nEOF\n)\""],
[2, "echo \"$(cat <<''\n)\n\ngit push --no-verify\n)\""],
[0, "echo \"$(cat <<''\n)\ngit push --no-verify\n\n)\""],
]) {
if (test(`delimiter role ${expected}: ${JSON.stringify(command)}`, () => {
const result = runHook(command);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git push/);
})) passed++; else failed++;
}
// Private VM instrumentation loads the exact source without changing the host
// globals, module cache, production API or executing any supplied command.
function countedClassification(command, quota) {
const context = vm.createContext({});
vm.runInContext(`
globalThis.copiedElements = 0;
const originalSlice = Array.prototype.slice;
Array.prototype.slice = function(start = 0, end = this.length) {
const a = start < 0 ? Math.max(0, this.length + start) : Math.min(this.length, start);
const b = end < 0 ? Math.max(0, this.length + end) : Math.min(this.length, end);
globalThis.copiedElements += Math.max(0, b - a);
return originalSlice.call(this, start, end);
};
`, context);
const lexer = { exports: {} };
const hookModule = { exports: {} };
const hooks = path.join(__dirname, '../../scripts/hooks');
const load = (file, module, require) => vm.compileFunction(fs.readFileSync(file, 'utf8').replace(/^#![^\n]*\n/, ''), ['module', 'require'], { parsingContext: context, filename: file })(module, require);
load(path.join(hooks, 'lib/shell-scan.js'), lexer, () => { throw new Error('Unexpected scanner dependency'); });
let spent = 0;
let valueReads = 0;
const instrumentedLexer = {
...lexer.exports,
createBudget(length) {
const budget = lexer.exports.createBudget(length);
return { spend(amount = 1) {
spent += amount;
// Throw in the module's own realm so its fail-closed catch is exercised.
if (quota !== undefined && spent > quota) vm.runInContext('throw new RangeError("Test work quota exceeded")', context);
budget.spend(amount);
} };
},
scanShell(text, budget) {
const scan = lexer.exports.scanShell(text, budget);
for (const command of scan.commands) {
for (const word of command.words) {
const value = word.value;
Object.defineProperty(word, 'value', { get() { valueReads++; return value; } });
}
}
return scan;
},
};
load(path.join(hooks, 'block-no-verify.js'), hookModule, name => {
assert.strictEqual(name, './lib/shell-scan');
return instrumentedLexer;
});
return { result: hookModule.exports.run(command), copiedElements: context.copiedElements, spent, valueReads };
}
for (const n of [64, 128]) {
if (test(`opaque Git candidates avoid quadratic suffix copies at ${n}`, () => {
const command = 'unknown ' + 'git '.repeat(n);
const result = countedClassification(command);
assert.strictEqual(result.result.exitCode, 0);
assert.ok(result.copiedElements <= 2 * (n + 1), JSON.stringify(result));
assert.ok(result.valueReads <= 12 * (n + 1), JSON.stringify(result));
})) passed++; else failed++;
if (test(`repeated global-option traversal spends the shared quota at ${n}`, () => {
const result = countedClassification('unknown git ' + '-c git '.repeat(n), 3000);
assert.strictEqual(result.result.exitCode, 2, JSON.stringify(result));
assert.match(result.result.stderr, /work budget/);
assert.ok(result.spent >= 3000 && result.spent < 3100, JSON.stringify(result));
assert.ok(result.valueReads < 6000, JSON.stringify(result));
})) passed++; else failed++;
}
// Unquoted heredoc ending delimiters use logical lines; quoted ones do not.
for (const quoted of [false, true]) {
for (const stripTabs of [false, true]) {
for (const nested of [false, true]) {
for (const backslashes of [1, 2, 3, 4]) {
const delimiter = quoted ? "'EOF'" : 'EOF';
const tab = stripTabs ? '\t' : '';
let command = `cat <<${stripTabs ? '-' : ''}${delimiter}\n${tab}EO${'\\'.repeat(backslashes)}\nF\ngit push --no-verify\n${tab}EOF\n`;
if (nested) command = `echo "$( ${command})"`;
const expected = !quoted && backslashes === 1 ? 2 : 0;
if (test(`heredoc logical ending quoted=${quoted} tabs=${stripTabs} nested=${nested} escapes=${backslashes}`, () => {
const result = runHook(command);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git push/);
})) passed++; else failed++;
}
}
}
}
for (const [expected, command] of [
[2, 'cat <<EOF\nE\\\nO\\\nF\ngit push --no-verify\n'],
[0, "cat <<'EOF'\nE\\\nO\\\nF\ngit push --no-verify\nEOF\n"],
[0, 'cat <<-EOF\n\tEO\\\n\tF\ngit push --no-verify\n\tEOF\n'],
[2, 'cat <<EOF\nhello\nEOF\ngit push --no-verify\n'],
[2, 'cat <<EOF\n$\\\n(git push --no-verify)\nEOF\n'],
[0, "cat <<'EOF'\n$\\\n(git push --no-verify)\nEOF\n"],
[0, 'cat <<EOF\n\\$\\\n(git push --no-verify)\nEOF\n'],
[2, "echo \"$(cat <<EOF\nEO\\\nF\ngit push --no-verify\n)\""],
[0, "echo \"$(cat <<'EOF'\nEO\\\nF\n)\ngit push --no-verify\nEOF\n)\""],
]) {
if (test(`joined heredoc role ${expected}: ${JSON.stringify(command)}`, () => {
const result = runHook(command);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git push/);
})) passed++; else failed++;
}
for (const option of ['-oerrexit', '+oerrexit', '-xoerrexit', '+xoerrexit', '-o errexit', '+o errexit', '-coerrexit']) {
for (const [expected, code, tail] of [
[2, 'git push --no-verify', ''],
[0, 'echo safe', " 'git push --no-verify'"],
]) {
const command = `zsh ${option} -c '${code}'${tail}`;
if (test(`zsh named option role ${expected}: ${command}`, () => {
const result = runHook(command);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git push/);
})) passed++; else failed++;
}
}
for (const [expected, command] of [
[2, "zsh -coerrexit 'git push --no-verify'"],
[0, "zsh -coerrexit 'echo safe' 'git push --no-verify'"],
[0, "zsh -oerrexit script.sh 'git push --no-verify'"],
[0, "zsh +oerrexit -- script.sh 'git push --no-verify'"],
[2, "bash +o errexit -c 'git push --no-verify'"],
[0, "bash +o errexit -c 'echo safe' 'git push --no-verify'"],
]) {
if (test(`shell-specific option control ${expected}: ${command}`, () => {
assert.strictEqual(runHook(command).code, expected);
})) passed++; else failed++;
}
// Named option arity is only modeled for Bash and the scoped zsh o grammar.
// For other literal shell names these are opaque, not guessed script operands.
for (const shell of ['sh', 'dash', 'ksh']) {
for (const option of ['-oerrexit', '+oerrexit', '-o errexit', '+o errexit', '-Oextglob', '+Oextglob', '-O extglob', '+O extglob']) {
for (const [expected, payload] of [[2, 'git push --no-verify'], [0, 'echo safe']]) {
const command = `${shell} ${option} -c '${payload}'`;
if (test(`opaque shell named option ${expected}: ${command}`, () => {
const result = runHook(command);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git push/);
})) passed++; else failed++;
}
}
for (const [expected, tail] of [
[2, "-oerrexit -c 'echo safe' 'git push --no-verify'"],
[2, "-O extglob script.sh 'git push --no-verify'"],
[0, "-c 'echo safe' 'git push --no-verify'"],
[2, "-c 'git push --no-verify'"],
[0, "script.sh 'git push --no-verify'"],
[2, "-s <<'EOF'\ngit push --no-verify\nEOF"],
[0, "-s <<'EOF'\necho safe\nEOF"],
]) {
if (test(`opaque versus supported ${shell}: ${JSON.stringify(tail)}`, () => {
// The first two are intentionally conservative refusals, including
// potentially inert positional data; no execution semantics are claimed.
const result = runHook(`${shell} ${tail}`);
assert.strictEqual(result.code, expected, result.stderr);
if (expected === 2) assert.match(result.stderr, /git push/);
})) passed++; else failed++;
}
}
const pureOnly = process.argv.includes('--pure-only');
if (pureOnly) console.log('Pure classifier mode: 3 bounded Node routing checks omitted.');
else {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-no-verify-'));
try {
for (const [name, input, disabled, code, direct] of [
['raw stdin passes through direct hook', 'git status', false, 0, true],
['JSON bypass blocks through runner', JSON.stringify({ tool_input: { command: 'git push --no-verify' } }), false, 2],
['disabled hook is silent', 'git push --no-verify', true, 0],
]) {
if (test(name, () => {
const args = direct ? [path.join(__dirname, '../../scripts/hooks/block-no-verify.js')] : [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'];
const result = spawnSync(process.execPath, args, {
input, encoding: 'utf8', timeout: 3000,
env: { PATH: path.dirname(process.execPath), HOME: home, USERPROFILE: home, TMPDIR: home, TMP: home, TEMP: home,
ECC_HOOK_PROFILE: 'standard', ECC_HOOK_CONFIG: path.join(home, 'absent.json'),
ECC_DISABLED_HOOKS: disabled ? 'pre:bash:block-no-verify' : '' },
stdio: ['pipe', 'pipe', 'pipe'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, code, result.stderr);
if (code === 0) assert.strictEqual(result.stdout, direct ? input : '');
if (disabled) assert.strictEqual(result.stderr, '');
if (code === 2) assert.match(result.stderr, /BLOCKED/);
})) passed++; else failed++;
}
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}
console.log('─'.repeat(50));
console.log(`Passed: ${passed} Failed: ${failed}`);