#!/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 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;&|$`(<{!"\']/.~\\'; /** * 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. */ function detectGitCommand(input) { let 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; } for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) { const cmdIdx = input.indexOf(cmd, git.idx + git.len); if (cmdIdx === -1) continue; const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' '; const after = input[cmdIdx + cmd.length] || ' '; if (!/\s/.test(before)) continue; if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') continue; if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) continue; if (isInComment(input, cmdIdx)) continue; return cmd; } start = git.idx + git.len; } return null; } /** * Check if the input contains a --no-verify flag for a specific git command. */ function hasNoVerifyFlag(input, command) { if (/--no-verify\b/.test(input)) return true; // For commit, -n is shorthand for --no-verify if (command === 'commit') { if (/\s-n(?:\s|$)/.test(input) || /\s-n[a-zA-Z]/.test(input)) return true; } return false; } /** * Check if the input contains a -c core.hooksPath= override. */ function hasHooksPathOverride(input) { return /-c\s+["']?core\.hooksPath\s*=/.test(input); } /** * Check a command string for git hook bypass attempts. */ function checkCommand(input) { const gitCommand = detectGitCommand(input); if (!gitCommand) return { blocked: false }; if (hasNoVerifyFlag(input, gitCommand)) { return { blocked: true, reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`, }; } if (hasHooksPathOverride(input)) { return { blocked: true, reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`, }; } return { blocked: false }; } /** * Extract the command string from hook input (JSON or plain text). */ 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. */ 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 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); });