diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js index 16e0044d7..8788b71b8 100644 --- a/scripts/hooks/block-no-verify.js +++ b/scripts/hooks/block-no-verify.js @@ -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 }; } diff --git a/scripts/hooks/lib/shell-scan.js b/scripts/hooks/lib/shell-scan.js new file mode 100644 index 000000000..0344f1bcb --- /dev/null +++ b/scripts/hooks/lib/shell-scan.js @@ -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 }; diff --git a/tests/hooks/block-no-verify.test.js b/tests/hooks/block-no-verify.test.js index 8b07d5f00..6d12502c4 100644 --- a/tests/hooks/block-no-verify.test.js +++ b/tests/hooks/block-no-verify.test.js @@ -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 < { + 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' < { + 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 < { + 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 < --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 < { + 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 << { + 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 < { + 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}`);