Files
ECC/scripts/hooks/block-no-verify.js
T
L4XB fc557e57ff fix(hooks): also read a group's writes joined in the order the shell receives them
The commands in a group write one stream into the pipe, so a bypass split across them, as in { printf 'git pu'; printf 'sh --no-verify'; } | sh, was read as two harmless fragments. groupSources() now adds the outputs joined in order next to the separate ones. The group handling moves out of pipelineSources() to keep it under the complexity limit.
2026-10-23 07:33:24 +02:00

1269 lines
52 KiB
JavaScript

#!/usr/bin/env node
/**
* PreToolUse Hook: Block --no-verify flag
*
* Blocks git hook-bypass flags (--no-verify, -c core.hooksPath=) to protect
* pre-commit, commit-msg, and pre-push hooks from being skipped by AI agents.
*
* Replaces the previous npx-based invocation that failed in pnpm-only projects
* (EBADDEVENGINES) and could not be disabled via ECC_DISABLED_HOOKS.
*
* Exit codes:
* 0 = allow (not a git command or no bypass flags)
* 2 = block (bypass flag detected)
*/
'use strict';
const { createBudget, scanShell } = require('./lib/shell-scan');
const MAX_STDIN = 1024 * 1024;
let raw = '';
// 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.
// See https://git-scm.com/docs/git-config — "The variable names are
// case-insensitive."
function isHookRedirectConfigKey(key) {
const normalized = key.toLowerCase();
return normalized === 'core.hookspath' || normalized === 'include.path'
|| /^includeif\..*\.path$/.test(normalized);
}
function isHookRedirectSetting(setting) {
return isHookRedirectConfigKey(setting.split('=', 1)[0]);
}
const COMMIT_OPTIONS_WITH_VALUE = new Set([
'-m',
'--message',
'-F',
'--file',
'-C',
'--reuse-message',
'-c',
'--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
// token as the option's value (template path 'n' here), so the scanner
// must stop at this character — anything after it is the inline value,
// not another flag.
const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']);
// Short options whose value is OPTIONAL and must be stuck to the flag
// (`-uno`, `-S<keyid>`). The rest of the cluster is that value, so an `n`
// 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']);
/**
* 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;
}
if (COMMIT_OPTIONS_WITH_VALUE.has(value)) {
return true;
}
const shortValueOption = getCommitShortValueOption(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;
}
if (COMMIT_OPTIONS_WITH_INLINE_VALUE.some(prefix => value.startsWith(prefix))) {
return true;
}
const shortValueOption = getCommitShortValueOption(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;
}
const options = value.slice(1);
for (let i = 0; i < options.length; i++) {
if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(options.charAt(i))) {
return {
consumesNextValue: i === options.length - 1,
containsInlineValue: i < options.length - 1
};
}
}
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;
}
// Short options cluster, so -n need not lead: `git commit -an` is -a plus -n
// and bypasses the hooks just as `-n` does. Anchoring on the first character
// let -an, -sn and -vn through.
//
// Scanning stops at a value-taking option because that option swallows the
// rest of the cluster as its inline value — the n in `-mn` is message text,
// not a flag.
const options = value.slice(1);
for (let i = 0; i < options.length; i++) {
const option = options.charAt(i);
if (option === 'n') return true;
if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(option)) return false;
if (COMMIT_SHORT_OPTIONS_WITH_OPTIONAL_VALUE.has(option)) return false;
}
return false;
}
/**
* 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
* `--no-ver` are ambiguous with --no-verbose and git rejects them itself, so
* refusing every prefix from `--no-v` up blocks nothing that would have run.
*/
function isNoVerifyLongFlag(value) {
return value.length >= '--no-v'.length && '--no-verify'.startsWith(value);
}
const PROTECTED_GIT_COMMANDS = new Set(['commit', 'push', 'merge', 'cherry-pick', 'rebase', 'am']);
const GIT_GLOBAL_VALUES = new Set(['-c', '-C', '--config-env', '--work-tree', '--git-dir', '--namespace', '--super-prefix']);
const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh']);
const DATA_COMMANDS = new Set(['echo', 'printf', 'cat', 'tee', 'grep', 'head', 'tail', 'wc', 'sort', 'uniq', ':', 'true', 'false']);
const CONTROL_WORDS = new Set(['!', 'if', 'then', 'elif', 'while', 'until', 'do', 'else']);
function basename(value) {
return value.replace(/\\/g, '/').split('/').pop();
}
function isGitExecutable(value) {
const name = basename(value).toLowerCase();
return name === 'git' || name === 'git.exe';
}
// Only literal values from this supplied shell task are tracked. No host
// environment, arbitrary expansion or external configuration is read.
function gitEnvironmentOverride(environment, budget) {
const count = environment.get('GIT_CONFIG_COUNT') || '';
budget.spend(count.length + environment.size + 1);
// Git uses strtoul: leading ASCII whitespace/+ are accepted, trailing bytes
// and counts above INT_MAX are rejected. Bound work by assignments we own.
const configured = /^[ \t\r\n\v\f]*\+?[0-9]+(?![\s\S])/.test(count) ? Number(count) : 0;
if (configured > 0 && configured <= 0x7fffffff && configured <= environment.size / 2) {
let override = false;
let complete = true;
for (let i = 0; i < configured; i++) {
budget.spend();
const key = environment.get(`GIT_CONFIG_KEY_${i}`);
if (key === undefined || !environment.has(`GIT_CONFIG_VALUE_${i}`)) { complete = false; break; }
budget.spend(key.length + 1);
override ||= isHookRedirectConfigKey(key);
}
if (complete && override) return true;
}
const parameters = environment.get('GIT_CONFIG_PARAMETERS');
if (parameters) {
budget.spend(parameters.length + 1);
// Git's old 'key=value' and new 'key'='value' forms both use quote removal.
// This inspects literal keys only; nested regions are never executed.
for (const command of scanShell(parameters, budget).commands) {
for (const word of command.words) {
budget.spend(word.value.length + 1);
if (isHookRedirectSetting(word.value)) return true;
}
}
}
return false;
}
// True when a git global option at words[index] redirects hooks via config.
// The separate-operand forms always charge the operand so budget accounting
// does not depend on whether an earlier option already matched.
function gitGlobalOptionRedirectsHooks(words, index, value, budget) {
if (value === '-c' || value === '--config-env') {
const setting = words[index + 1]?.value || '';
budget.spend(setting.length + 1);
return isHookRedirectSetting(setting);
}
if (value.startsWith('-c')) return isHookRedirectSetting(value.slice(2));
if (value.startsWith('--config-env=')) return isHookRedirectSetting(value.slice('--config-env='.length));
return false;
}
// Walk git's global options after the executable. Returns the index of the
// subcommand word and whether any option redirected the hooks path.
function scanGitGlobalOptions(words, budget, start, environmentOverride) {
let index = start + 1;
let override = environmentOverride;
for (; index < words.length; index++) {
const value = words[index].value;
budget.spend(value.length + 1);
if (!value.startsWith('-')) break;
if (value === '--') { index++; break; }
if (gitGlobalOptionRedirectsHooks(words, index, value, budget)) override = true;
if (GIT_GLOBAL_VALUES.has(value)) index++;
}
return { index, override };
}
// Scan a protected subcommand's own arguments for a hook-bypass flag.
function findGitBypassFlag(words, budget, start, command) {
let skipNext = false;
for (let index = start; 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 (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) return true;
}
return false;
}
function checkGitWords(words, budget, start = 0, environmentOverride = false) {
const { index, override } = scanGitGlobalOptions(words, budget, start, environmentOverride);
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.`;
if (findGitBypassFlag(words, budget, index + 1, command)) {
return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`;
}
return null;
}
// Keep literal outcomes and the empty result of an unresolved expansion. The
// latter is a base for later visible += operands, not arbitrary evaluation.
function assignmentValues(prior, operand, append, dynamic, budget) {
const base = prior === undefined ? '' : prior;
budget.spend((append ? base.length : 0) + operand.length + 1);
const values = new Set([append ? base + operand : operand]);
if (dynamic) {
if (prior !== undefined) values.add(prior);
values.add(append ? base : '');
}
return [...values];
}
class UnsupportedEnvSplitEscape extends Error {}
class UnsupportedSedEscape extends Error {}
function validateEnvSplitEscapes(payload, budget) {
let quote = null;
for (let index = 0; index < payload.length; index++) {
budget.spend();
const character = payload[index];
// env -S has its own escapes. Only literal single-quoted backslashes
// (excluding env's \\ and \' escapes) agree with the shell scanner.
if (character === '\\' && (quote !== "'" || payload[index + 1] === '\\' || payload[index + 1] === "'")) {
throw new UnsupportedEnvSplitEscape();
}
if (character === "'" && quote !== '"') quote = quote === "'" ? null : "'";
else if (character === '"' && quote !== "'") quote = quote === '"' ? null : '"';
}
}
const ENV_VALUE_OPTIONS = new Set(['-u', '--unset', '-C', '--chdir']);
const ENV_FLAG_OPTIONS = new Set(['-i', '--ignore-environment', '-0', '--null']);
const SUDO_VALUE_OPTIONS = new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']);
const SUDO_FLAG_OPTIONS = new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']);
const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/;
function isEnvSplitFlag(flag) {
return flag === '-S' || flag === '--split-string'
|| flag.startsWith('--split-string=') || flag.startsWith('-S');
}
// A value-taking wrapper option with its operand attached (`-uNAME`, `--unset=NAME`).
function hasAttachedOperand(flag, values) {
return [...values].some(value => value.startsWith('--')
? flag.startsWith(`${value}=`)
: flag.startsWith(value) && flag.length > value.length);
}
// Only literal word splitting is understood for env -S. Keep complex env
// syntax opaque rather than pretending to execute it as a shell.
function isLiteralWordList(scan) {
const command = scan.commands[0];
return scan.commands.length === 1 && !command.nested.length
&& !command.redirects.length && !command.words.some(word => word.dynamic);
}
// 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, inherited = new Map(), callerValues = inherited) {
budget.spend(inherited.size + 1);
const environments = [new Map(inherited)];
const prefixAssignments = new Map();
const dynamicAssignments = new Set();
let local = true;
let assignmentOnly = true;
let i = 0;
let assignments = true;
let environmentAssignments = false;
function result(values) { return { words: values, environments, prefixAssignments, local, assignmentOnly, dynamicAssignments }; }
function suffix(start) {
budget.spend(words.length - start);
assignmentOnly = false;
return result(words.slice(start));
}
function assignment(token) {
const { value, dynamic } = token;
const equals = value.indexOf('=');
const append = !environmentAssignments && value[equals - 1] === '+';
const key = value.slice(0, append ? equals - 1 : equals);
if (GIT_ENV_NAME.test(key)) {
const operand = value.slice(equals + 1);
const count = environments.length;
budget.spend(count + 1);
for (let n = 0; n < count; n++) {
const environment = environments[n];
// Shell prefix appends can see local values, even when not exported.
// Repeated operands use the prior outcome in this same prefix.
const prior = prefixAssignments.has(key) && environment.has(key)
? environment.get(key) : callerValues.get(key);
const values = assignmentValues(prior, operand, append, dynamic, budget);
for (const alternative of values.slice(1)) {
budget.spend(environment.size + 1);
const variant = new Map(environment);
variant.set(key, alternative);
environments.push(variant);
}
environment.set(key, values[0]);
}
// Retain ordered operations so same-shell states apply each append once.
if (!prefixAssignments.has(key)) prefixAssignments.set(key, []);
prefixAssignments.get(key).push({ value: operand, append, dynamic });
if (dynamic) dynamicAssignments.add(key);
}
}
function resetEnvironment(name) {
budget.spend(environments.length + 1);
for (const environment of environments) {
if (name === undefined) environment.clear();
else environment.delete(name);
}
}
// Wrapper handlers below return a final result to stop scanning, or
// undefined once the wrapper's own options are consumed and the scan
// should continue with the wrapped command.
function commandBuiltin(token) {
assignmentOnly = false;
local &&= token.value === '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 result([]);
if (!/^-p+$/.test(flag)) return suffix(i - 1);
}
assignments = false;
return undefined;
}
function execBuiltin() {
assignmentOnly = false;
local = false;
i++;
while (words[i]?.value.startsWith('-')) {
const flag = words[i++].value;
budget.spend(flag.length + 1);
if (flag === '--') break;
if (/^-[cl]*a$/.test(flag)) i++;
else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1);
if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment();
}
assignments = false;
return undefined;
}
// env -S splits a literal payload into argv words in place; words[i] is
// then re-examined as the first expanded word.
function envSplitString(flag) {
const separated = flag === '-S' || flag === '--split-string';
const payload = separated ? words[i + 1]?.value || ''
: flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2);
budget.spend(payload.length + 1);
validateEnvSplitEscapes(payload, budget);
const scan = scanShell(payload, budget);
if (!isLiteralWordList(scan)) return suffix(i - 1);
// Pay for both slices and the expanded array before allocating them.
budget.spend(2 * words.length + scan.commands[0].words.length);
words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))];
return undefined;
}
function envResetFlag(flag) {
if (flag === '-i' || flag === '--ignore-environment') resetEnvironment();
if (flag === '-u' || flag === '--unset') resetEnvironment(words[i + 1]?.value || '');
else if (flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length));
else if (flag.startsWith('-u')) resetEnvironment(flag.slice(2));
}
function envWrapper(name) {
assignmentOnly = false;
local = false;
const env = name === 'env';
const values = env ? ENV_VALUE_OPTIONS : SUDO_VALUE_OPTIONS;
const flags = env ? ENV_FLAG_OPTIONS : SUDO_FLAG_OPTIONS;
i++;
while (words[i]?.value.startsWith('-')) {
const flag = words[i].value;
budget.spend(flag.length + 1);
if (flag === '--') { i++; break; }
if (env && isEnvSplitFlag(flag)) {
const stop = envSplitString(flag);
if (stop) return stop;
continue;
}
if (env) envResetFlag(flag);
if (values.has(flag)) i += 2;
else if (flags.has(flag) || hasAttachedOperand(flag, values)) i++;
else return suffix(i - 1); // Includes opaque env -S / sudo shell modes.
}
assignments = true;
environmentAssignments = true;
return undefined;
}
while (i < words.length) {
const token = words[i];
budget.spend(token.value.length + token.raw.length + 1);
if (assignments && SHELL_ASSIGNMENT.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; }
if (!token.quoted && CONTROL_WORDS.has(token.value)) { i++; continue; }
const name = basename(token.value);
let stop;
if (name === 'command') stop = commandBuiltin(token);
else if (name === 'exec') stop = execBuiltin();
else if (name === 'env' || name === 'sudo' || name === 'doas') stop = envWrapper(name);
else return suffix(i);
if (stop) return stop;
}
return result([]);
}
const SHELL_LONG_FLAGS = ['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'];
const OPAQUE_SHELL = { kind: 'opaque', stdin: true };
// Classify one short shell flag inside an option cluster.
function classifyShellFlag(flag, shell) {
// 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 'opaque';
if (flag === 'c') return 'code';
if (flag === 's') return 'stdin';
// 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 (shell === 'zsh' && flag === 'o') return 'zshOption';
// These are not Bash's operand grammar; unmodeled zsh modes stay opaque.
if (shell === 'zsh' && (flag === 'O' || flag === 'b')) return 'opaque';
if (flag === 'o' || flag === 'O') return 'operand';
return 'abefhiklmnprtuvxBCEHPTD'.includes(flag) ? 'plain' : 'opaque';
}
// 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.
// Returns null when the cluster makes the invocation opaque.
function scanShellOptionCluster(option, words, start, shell, budget) {
const cluster = { next: start + 1, code: false, stdin: false };
for (let j = 1; j < option.length; j++) {
budget.spend();
const kind = classifyShellFlag(option[j], shell);
if (kind === 'opaque') return null;
if (kind === 'code') cluster.code = true;
else if (kind === 'stdin') cluster.stdin = true;
else if (kind === 'zshOption') {
if (j + 1 === option.length && cluster.next < words.length) cluster.next++;
return cluster;
} else if (kind === 'operand' && cluster.next < words.length) cluster.next++;
}
return cluster;
}
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 (!SHELL_LONG_FLAGS.includes(option)) return { ...OPAQUE_SHELL };
i++; continue;
}
const cluster = scanShellOptionCluster(option, words, i, shell, budget);
if (!cluster) return { ...OPAQUE_SHELL };
code ||= cluster.code;
stdin ||= cluster.stdin;
i = cluster.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 };
}
// --- GNU sed execution model -------------------------------------------------
// sed is a text transformer, not a shell: its script operand and file operands
// are data. Two constructs break that: the `e` command (with or without an
// operand, the operand or pattern space is run through the shell) and an `s`
// command carrying the `e` flag (the substituted pattern space is run through
// the shell). Scripts using them make sed a shell-equivalent receiver; every
// other invocation keeps its operands out of the executable scan. Anything the
// literal parser cannot confidently read falls back to today's opaque default.
// Skip an optional address (or range) before a command letter. Returns the
// index of the command letter, or -1 when an address starts but cannot be
// confidently consumed.
function skipSedAddress(script, i, budget) {
const n = script.length;
function one(i) {
if (i >= n) return -1;
const c = script[i];
if (c === '$') return i + 1;
if (/[0-9]/.test(c)) {
let j = i;
while (j < n && /[0-9]/.test(script[j])) { budget.spend(); j++; }
return j;
}
const delimChar = c === '/' ? '/' : (c === '\\' ? script[i + 1] : null);
if (delimChar) {
let j = c === '/' ? i + 1 : i + 2;
while (j < n) {
budget.spend();
if (script[j] === '\\') { j += 2; continue; }
if (script[j] === delimChar) return j + 1;
if (script[j] === '\n') return -1;
j++;
}
return -1;
}
return -1;
}
const first = one(i);
if (first === -1) return i; // no address; the command letter is at i
let j = first;
if (script[j] === ',') {
const second = one(j + 1);
if (second === -1) return -1;
j = second;
}
if (script[j] === '!') j++;
return j;
}
// Read one delimiter-bounded sed section (pattern, replacement or
// transliteration set) starting at script[start]. Returns the index of the
// closing delimiter, or -1 when the section cannot be confidently read.
function readSedDelimited(script, start, delim, budget) {
let j = start;
while (j < script.length) {
budget.spend();
if (script[j] === '\\') { j += 2; continue; }
if (script[j] === delim) return j;
if (script[j] === '\n') return -1;
j++;
}
return -1;
}
// Parse one sed s-command (or y-command, which shares the shape but takes no
// flags) at script[i]. Returns { end, replacement, exec }, or null when the
// shape is not confidently literal.
function parseSedSubstitution(script, i, budget, transliterate = false) {
const delim = script[i + 1];
if (delim === undefined || /[a-zA-Z0-9\\\s]/.test(delim)) return null;
const patternEnd = readSedDelimited(script, i + 2, delim, budget);
if (patternEnd === -1) return null;
const replacementEnd = readSedDelimited(script, patternEnd + 1, delim, budget);
if (replacementEnd === -1) return null;
const replacement = script.slice(patternEnd + 1, replacementEnd);
let j = replacementEnd + 1;
if (transliterate) return { end: j, replacement: '', exec: false };
// GNU flag letters. `e` executes; `E` only selects extended regex and must
// not be treated as execution. `w file` appends a filename operand (data).
let exec = false;
while (j < script.length && /[0-9gGpPiImMe]/.test(script[j])) {
budget.spend();
if (script[j] === 'e') exec = true;
j++;
}
if (script[j] === 'w') {
while (j < script.length && script[j] !== '\n') { budget.spend(); j++; }
}
return { end: j, replacement, exec };
}
// Exec-bearing fragments (the s///e replacement, the e command operand) are
// decoded by GNU sed before execution: \xHH, \oOOO, \dNNN, case operations and
// even unknown escapes like \g all rewrite the text. Decoding that surface
// faithfully is out of scope, so any backslash in an executed fragment keeps
// the hook from proving safety — the caller blocks, mirroring the env -S
// escape precedent rather than pretending to interpret sed.
function assertSedLiteral(text) {
if (text.includes('\\')) throw new UnsupportedSedEscape();
}
// The `e` command: with an operand, the operand runs through the shell; bare,
// the pattern space (input data) does.
function readSedExecute(script, i, budget) {
let j = i + 1;
while (j < script.length && (script[j] === ' ' || script[j] === '\t')) { budget.spend(); j++; }
if (j < script.length && script[j] !== '\n' && script[j] !== ';') {
let end = j;
while (end < script.length && script[end] !== '\n') { budget.spend(); end++; }
const operand = script.slice(j, end);
// A trailing backslash is an escaped newline: GNU sed joins the next line
// into this one operand, which the literal check below refuses.
assertSedLiteral(operand);
return { end, parts: [operand], stdinExec: true };
}
return { end: j, parts: [], stdinExec: true };
}
// Text and filename operands run to end of line; all are data.
function readSedLineOperand(script, i, budget) {
let end = i + 1;
while (end < script.length && script[end] !== '\n') { budget.spend(); end++; }
return { end, parts: [], stdinExec: false };
}
// Branch and label commands (b, t, T, :) take a label that ends at `;` or
// newline, verified against GNU sed 4.9: ':a;e cmd' and 'ba;e cmd' both
// parse the label up to `;` and still run the following `e`. Commands after
// the label therefore continue to be scanned, so return the label end and
// let the caller resume command parsing there.
function readSedLabel(script, i, budget) {
let end = i + 1;
while (end < script.length && script[end] !== ';' && script[end] !== '\n') { budget.spend(); end++; }
return { end, parts: [], stdinExec: false };
}
function readSedCommand(script, i, budget) {
const letter = script[i];
if (letter === 's') {
const sub = parseSedSubstitution(script, i, budget);
if (!sub) return null;
if (!sub.exec) return { end: sub.end, parts: [], stdinExec: false };
// The executed text also carries matched input data (&, backrefs), so a
// piping producer must be treated as a shell source too.
assertSedLiteral(sub.replacement);
return { end: sub.end, parts: sub.replacement ? [sub.replacement] : [], stdinExec: true };
}
if (letter === 'y') {
const sub = parseSedSubstitution(script, i, budget, true);
return sub ? { end: sub.end, parts: [], stdinExec: false } : null;
}
if (letter === 'e') return readSedExecute(script, i, budget);
if ('btT:'.includes(letter)) return readSedLabel(script, i, budget);
if ('acirwWqQ'.includes(letter)) return readSedLineOperand(script, i, budget);
if ('dDgGhHlnNpPxFzv='.includes(letter)) return { end: i + 1, parts: [], stdinExec: false };
return null; // unmodeled command letter: keep the opaque default
}
// Returns { parts, stdinExec } where parts are literal script fragments sed
// will hand to the shell, or null when the script cannot be confidently read.
function sedExecParts(script, budget) {
if (script.includes('\u0000')) return null; // expansion residue: not literal
const parts = [];
let stdinExec = false;
let i = 0;
while (i < script.length) {
budget.spend();
const c = script[i];
if (c === ' ' || c === '\t' || c === '\n' || c === ';' || c === '{' || c === '}') { i++; continue; }
if (c === '#') {
while (i < script.length && script[i] !== '\n') { budget.spend(); i++; }
continue;
}
if (c === '\\') return null; // escape in command position: unmodeled
const addressed = skipSedAddress(script, i, budget);
if (addressed === -1) return null;
if (addressed >= script.length) break;
const step = readSedCommand(script, addressed, budget);
if (!step || step.end <= addressed) return null;
parts.push(...step.parts);
stdinExec ||= step.stdinExec;
i = step.end;
}
return { parts, stdinExec };
}
// Model sed's argv: which operands are scripts, which are file names (data).
// GNU sed collects -e/--expression scripts from ANY argv position — a later -e
// after positional operands still executes — so options are parsed through the
// whole argv. When any script option is given, every positional is a file;
// otherwise the first positional is the script (verified against GNU sed 4.9).
// Returns the script strings, or null when the invocation shape is unmodeled —
// the caller then keeps today's opaque default instead of guessing.
function sedScriptParts(words, budget) {
const scripts = [];
const positionals = [];
let i = 1;
while (i < words.length) {
const raw = words[i].value;
budget.spend(raw.length + 1);
if (raw === '--') {
// Options end here, but the operands do not: with no script option
// given, the first operand after -- is still the script (GNU sed 4.9
// verified). Keep collecting them.
i++;
while (i < words.length) { positionals.push(words[i].value); budget.spend(words[i].value.length + 1); i++; }
break;
}
if (raw === '--expression') {
if (!words[i + 1]) return null;
scripts.push(words[i + 1].value); i += 2; continue;
}
if (raw.startsWith('--expression=')) { scripts.push(raw.slice('--expression='.length)); i++; continue; }
if (raw.startsWith('--')) {
if (['--quiet', '--silent', '--null-data', '--zero-terminated', '--posix', '--regexp-extended', '--sandbox', '--separate', '--unbuffered', '--follow-symlinks'].includes(raw)) { i++; continue; }
return null; // --file=, --in-place variants, unknown long options
}
if (raw.startsWith('-') && raw.length > 1) {
if (raw === '-e') {
if (!words[i + 1]) return null;
scripts.push(words[i + 1].value); i += 2; continue;
}
if (raw === '-f') return null; // script comes from a file we cannot read
if (/^-l[0-9]+$/.test(raw)) { i++; continue; }
if (/^-[nsEruzb]+$/.test(raw)) { i++; continue; }
return null; // -i suffix ambiguity, unknown/attached short options
}
positionals.push(raw); i++; // operands: first becomes the script only when no script option was given
}
if (!scripts.length) {
if (!positionals.length) return null; // no script operand at all
scripts.push(positionals[0]);
}
// With any -e/--expression present, every positional is a file: data,
// deliberately left uninspected.
return scripts;
}
function commandRole(words, budget) {
if (!words.length) return { kind: 'data' };
budget.spend(words[0].value.length + 1);
const name = basename(words[0].value);
if (isGitExecutable(words[0].value)) 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 (name === 'sed') {
const scripts = sedScriptParts(words, budget);
if (!scripts) return { kind: 'opaque', stdin: true };
const parts = [];
let stdinExec = false;
for (const script of scripts) {
const parsed = sedExecParts(script, budget);
if (!parsed) return { kind: 'opaque', stdin: true };
parts.push(...parsed.parts);
stdinExec ||= parsed.stdinExec;
}
if (parts.length || stdinExec) return { kind: 'sedExec', stdin: true, parts };
// Pure text transformation: script and file operands are data. This is the
// specific false positive the #3051 tests pin: file names that merely
// contain "git ..." never execute.
return { kind: 'data' };
}
if (DATA_COMMANDS.has(name)) return { kind: 'data' };
return { kind: 'opaque', stdin: true };
}
// A brace group or subshell writes what its own commands print into the pipe:
// `{ echo 'git push --no-verify'; } | sh` feeds sh like `echo ... | sh`. The
// writes form one stream, so they are also read joined in order:
// `{ printf 'git pu'; printf 'sh --no-verify'; } | sh`.
function groupSources(scope, budget) {
const written = [];
for (const inner of scanShell(scope.body, budget).commands) {
if (!inner.pipeTo) written.push(...pipelineSources(inner, budget));
}
if (written.length > 1) written.push({ text: written.map(source => source.text).join(''), opaque: written.some(source => source.opaque) });
return written;
}
// 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) {
if (current.closedScope) sources.push(...groupSources(current.closedScope, budget));
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({ text: 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({ text: (format === '%s' || format === '%s\\n') ? words.slice(2).map(word => word.value).join('\n') : words.slice(1).map(word => word.value).join(' ') });
}
if (!DATA_COMMANDS.has(name)) {
// Foreign transformations can introduce literal bypasses into executable
// stdin. Treat their punctuation as delimiters, not as proved shell syntax.
// This deliberately may refuse a transformation that removes a bypass; it
// does not evaluate sed/interpreters or detect arbitrary generated source.
const text = words.map(word => word.value).join(' ');
budget.spend(2 * text.length + 1);
sources.push({ text: text.replace(/[^\w$=.+-]/g, ' '), opaque: true });
}
for (const redirect of current.redirects) {
if (redirect.operator === '<<<') sources.push({ text: redirect.word.value });
else if (redirect.operator === '<<' || redirect.operator === '<<-') sources.push({ text: redirect.body });
}
}
return sources;
}
const DECLARATIONS = new Set(['export', 'declare', 'typeset', 'readonly', 'unset']);
function shellState(environment, budget) {
budget.spend(2 * environment.size + 1);
return { variables: new Map(environment), exported: new Set(environment.keys()), readonly: new Set() };
}
function copyShellState(state, budget) {
budget.spend(state.variables.size + state.exported.size + state.readonly.size + 1);
return { variables: new Map(state.variables), exported: new Set(state.exported), readonly: new Set(state.readonly) };
}
function copyShellContext(context, budget) {
budget.spend(context.states.length + 1);
return { states: context.states.map(state => copyShellState(state, budget)) };
}
function exportedEnvironment(state, budget) {
const environment = new Map();
budget.spend(state.exported.size + 1);
for (const name of state.exported) {
if (state.variables.has(name)) environment.set(name, state.variables.get(name));
}
return environment;
}
// Literal declaration operands are data, not executable source. A value and
// its export attribute are separate: an assignment-only command does not start
// exporting a previously local variable. No host shell state is consulted.
function updateShellState(state, normalized, budget) {
const { words, prefixAssignments, local, assignmentOnly, dynamicAssignments } = normalized;
const states = [state];
const result = (handled, changed, uncertain = false) => ({ handled, changed, uncertain, states });
if (!local) return result(false, false);
function assign(name, value, dynamic = false, append = false) {
const count = states.length;
budget.spend(count + 1);
for (let n = 0; n < count; n++) {
const current = states[n];
if (current.readonly.has(name)) continue;
const values = assignmentValues(current.variables.get(name), value, append, dynamic, budget);
for (const alternative of values.slice(1)) {
const variant = copyShellState(current, budget);
variant.variables.set(name, alternative);
states.push(variant);
}
current.variables.set(name, values[0]);
}
}
function assignPrefixes() {
budget.spend(prefixAssignments.size + 1);
for (const [key, operations] of prefixAssignments) {
budget.spend(operations.length + 1);
for (const operation of operations) assign(key, operation.value, operation.dynamic, operation.append);
}
}
if (assignmentOnly) {
assignPrefixes();
return result(true, prefixAssignments.size > 0, dynamicAssignments.size > 0);
}
// Exact builtin names only: /some/path/export is an external executable.
const name = words[0]?.value;
if (!DECLARATIONS.has(name)) return result(false, false);
const declaration = parseDeclarationFlags(name, words, budget, dynamicAssignments.size > 0);
if (declaration.passive && !declaration.uncertain) return result(true, false);
let changed = prefixAssignments.size > 0;
assignPrefixes();
for (let i = declaration.index; i < words.length; i++) {
const value = words[i].value;
budget.spend(2 * value.length + 1);
const equals = value.indexOf('=');
const append = equals > 0 && value[equals - 1] === '+';
const key = equals < 0 ? value : value.slice(0, append ? equals - 1 : equals);
if (!GIT_ENV_NAME.test(key)) continue;
changed = true;
if (name !== 'unset' && equals >= 0) assign(key, value.slice(equals + 1), words[i].dynamic, append);
budget.spend(states.length + 1);
for (const current of states) applyDeclaration(current, key, equals, declaration);
}
// Unsupported attributes may transform values or reject the declaration.
// Retain old and conservative literal states; never use them to prove reset.
return result(true, changed, declaration.uncertain);
}
// Fold one declaration builtin flag into the parsed attribute set.
function applyDeclarationFlag(declaration, flag) {
const { name } = declaration;
if (name === 'export' && /^-[npf]+$/.test(flag)) {
if (flag.includes('n')) declaration.exported = false;
declaration.passive ||= flag.includes('f');
return;
}
if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) {
if (flag.includes('x')) declaration.exported = flag[0] === '-';
if (flag[0] === '-' && flag.includes('r')) declaration.readonly = true;
declaration.passive ||= /[pf]/.test(flag);
return;
}
if (name === 'readonly' && /^-[pf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; }
if (name === 'unset' && /^-[vf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; }
declaration.uncertain = true;
}
// Parse the option words of export/declare/typeset/readonly/unset. Returns the
// attributes they set and the index of the first operand.
function parseDeclarationFlags(name, words, budget, uncertain) {
const declaration = {
name,
exported: name === 'export' ? true : null,
readonly: name === 'readonly',
passive: false,
uncertain,
index: 1
};
for (; declaration.index < words.length; declaration.index++) {
const flag = words[declaration.index].value;
budget.spend(flag.length + 1);
if (flag === '--') { declaration.index++; break; }
if (!/^[+-]/.test(flag)) break;
applyDeclarationFlag(declaration, flag);
}
return declaration;
}
// Apply a parsed declaration of `key` to one literal shell state.
function applyDeclaration(current, key, equals, declaration) {
const { name, exported, readonly, uncertain } = declaration;
if (name === 'unset') {
if (equals < 0 && !current.readonly.has(key)) {
current.variables.delete(key); current.exported.delete(key);
}
return;
}
if (exported === true || uncertain) current.exported.add(key);
else if (exported === false) current.exported.delete(key);
if (readonly || uncertain) current.readonly.add(key);
}
function checkCommand(input) {
const budget = createBudget(input.length);
const pending = [{ text: input, opaque: false, context: { states: [shellState(new Map(), budget)] } }];
function enqueue(text, opaque = false, context = { states: [shellState(new Map(), budget)] }) {
if (!text) return;
budget.spend(text.length + 1);
pending.push({ text, opaque, context });
}
function inspectOpaque(words, text, environment) {
for (let index = 0; index < words.length; index++) {
const word = words[index];
budget.spend(word.value.length + 1);
if (isGitExecutable(word.value)) {
const reason = checkGitWords(words, budget, index, gitEnvironmentOverride(environment, budget));
if (reason) return reason;
}
if (word.value !== text && /git/i.test(word.value) && /[\s'"()]/.test(word.value)) enqueue(word.value, true, { states: [shellState(environment, budget)] });
}
return null;
}
// Split a text task into one pending task per scanned command.
function expandText(task) {
const scan = scanShell(task.text, budget);
const contexts = new Map([[scan.rootScope, task.context]]);
budget.spend(scan.commands.length + 1);
for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts });
}
// A closing scope folds its states back into the enclosing context when the
// scope ended a pipeline.
function exitScope(command, contexts) {
const closing = command.scopeExit;
const exited = contexts.get(closing);
const enclosing = contexts.get(closing.parent);
if (closing.pipelineLast && exited && enclosing) {
budget.spend(exited.states.length + 1);
enclosing.states.push(...exited.states);
}
}
// Materialize contexts for any scopes between the command and the nearest
// known ancestor, then return the command's own scope context.
function resolveScope(command, contexts) {
const missing = [];
for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); }
while (missing.length) {
const scope = missing.pop();
const parent = contexts.get(scope.parent);
contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent);
}
return contexts.get(command.scope);
}
function isConditional(scope) {
let conditional = false;
for (let current = scope; current; current = current.parent) { budget.spend(); conditional ||= current.conditional; }
return conditional;
}
// Stdin fed to a shell-like receiver (here-strings, heredocs, literal pipe
// producers) is scanned as source. Opaque receivers keep their text opaque.
function collectStdinSources(frame, role, environment) {
const opaque = role.kind === 'opaque';
for (const redirect of frame.command.redirects) {
if (redirect.operator === '<<<') frame.childEnvironments.push({ code: redirect.word.value, opaque, environment });
else if (redirect.operator === '<<' || redirect.operator === '<<-') frame.childEnvironments.push({ code: redirect.body, opaque, environment });
}
if (frame.command.pipeFrom) {
for (const source of pipelineSources(frame.command.pipeFrom, budget)) frame.childEnvironments.push({ code: source.text, opaque: source.opaque || opaque, environment });
}
}
// Inspect one command under one literal environment. Returns a block reason
// or null, and records any child source text the command would execute.
function inspectEnvironment(frame, role, normalized, environment) {
const { task, command } = frame;
const { words } = normalized;
const reason = task.opaque || role.kind === 'opaque'
? inspectOpaque(command.words, task.text, environment)
: role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null;
if (reason) return reason;
if (role.code) {
if (normalized.local && words[0]?.value === 'eval') frame.sameShellCode = role.code;
else frame.childEnvironments.push({ code: role.code, opaque: false, environment });
}
if (role.kind === 'sedExec') {
// Fragments sed will hand to the shell are scanned as real shell
// source, not as opaque text — this closes the GNU sed `e` gap.
for (const part of role.parts) {
budget.spend(part.length + 1);
frame.childEnvironments.push({ code: part, opaque: false, environment });
}
}
if (role.stdin) collectStdinSources(frame, role, environment);
return null;
}
// Advance one literal shell state through the command: apply declarations
// and prefix assignments, then inspect the executable under each resulting
// environment.
function inspectState(frame, state) {
const { command, conditional } = frame;
const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables);
const { words, environments } = normalized;
const next = copyShellState(state, budget);
const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0;
const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget);
if (evalPrefix) {
frame.alternatives.push(state);
budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1));
for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name);
}
budget.spend(mutation.states.length + 1);
frame.alternatives.push(...mutation.states);
if (mutation.changed && (conditional || mutation.uncertain)) frame.alternatives.push(state);
frame.changed ||= mutation.changed;
if (mutation.handled && !evalPrefix) return null;
const role = commandRole(words, budget);
budget.spend(environments.length + 1);
for (const environment of environments) {
const reason = inspectEnvironment(frame, role, normalized, environment);
if (reason) return reason;
}
return null;
}
// Commit the command's resulting states and queue whatever it would run.
function finishCommand(frame) {
const { command, context, parent, conditional, sameShellCode, changed } = frame;
context.states = frame.alternatives;
// Bash lastpipe and zsh can execute a final pipeline builtin in the
// parent shell. Preserve that possible state as well as isolation; this
// is deliberately conservative when the host shell/options are unknown.
if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context });
for (const child of frame.childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] });
if (sameShellCode) {
// A conditional eval may not run. Its nested scans have fresh lexical
// roots, so preserve the skipped branch across all delayed updates.
const evaluated = conditional ? copyShellContext(context, budget) : context;
if (conditional) pending.push({ mergeInto: context, context: evaluated });
enqueue(sameShellCode, false, evaluated);
}
}
// Process one scanned command task. Returns a block reason or null.
function processCommand(task) {
const { command, contexts } = task;
if (command.scopeExit) { exitScope(command, contexts); return null; }
const parent = resolveScope(command, contexts);
const isolated = command.pipeFrom || command.pipeTo || command.background;
const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent);
if (!task.nestedDone && command.nested.length) {
pending.push({ ...task, nestedDone: true, commandContext: context });
budget.spend(command.nested.length + 1);
for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget));
return null;
}
const frame = {
task, command, context, parent,
conditional: isConditional(command.scope),
alternatives: [], childEnvironments: [], sameShellCode: null, changed: false
};
budget.spend(context.states.length + 1);
for (const state of context.states) {
const reason = inspectState(frame, state);
if (reason) return reason;
}
finishCommand(frame);
return null;
}
try {
while (pending.length) {
const task = pending.pop();
if (task.mergeInto) {
budget.spend(task.context.states.length + 1);
task.mergeInto.states.push(...task.context.states);
continue;
}
if (!task.command) { expandText(task); continue; }
const reason = processCommand(task);
if (reason) return { blocked: true, reason };
}
} catch (error) {
if (error instanceof UnsupportedEnvSplitEscape) {
return { blocked: true, reason: 'BLOCKED: Unsupported env split-string escape; hook-bypass safety could not be established.' };
}
if (error instanceof UnsupportedSedEscape) {
return { blocked: true, reason: 'BLOCKED: Unsupported sed script escape; hook-bypass safety could not be established.' };
}
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;
}
try {
const parsed = JSON.parse(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;
}
// Generic JSON formats
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
if (typeof parsed[key] === 'string') {
return parsed[key];
}
}
return trimmed;
} catch {
return trimmed;
}
}
/**
* 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);
const result = checkCommand(command);
if (result.blocked) {
return {
exitCode: 2,
stderr: result.reason
};
}
return { exitCode: 0 };
}
module.exports = { run };
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const command = extractCommand(raw);
const result = checkCommand(command);
if (result.blocked) {
process.stderr.write(result.reason + '\n');
process.exit(2);
}
process.stdout.write(raw);
});
}