From dae663d85664887e438747e1af9db245080da4d1 Mon Sep 17 00:00:00 2001 From: Ke Wang Date: Sun, 12 Apr 2026 19:53:15 -0500 Subject: [PATCH] fix: route block-no-verify hook through run-with-flags.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline `npx block-no-verify@1.1.2` with a standalone Node.js script routed through `run-with-flags.js`, matching every other hook. Fixes two bugs: 1. npx inherits the project cwd and triggers EBADDEVENGINES in pnpm-only projects that set devEngines.packageManager.onFail=error. 2. The hook bypassed run-with-flags.js so ECC_DISABLED_HOOKS had no effect — the isHookEnabled() check never ran. The new script replicates the full block-no-verify@1.1.2 detection logic (--no-verify, -n shorthand for commit, core.hooksPath override) with zero external dependencies. Closes #1378 --- hooks/hooks.json | 2 +- scripts/hooks/block-no-verify.js | 219 +++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 scripts/hooks/block-no-verify.js diff --git a/hooks/hooks.json b/hooks/hooks.json index 528b03f8..8cba1b8f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "npx block-no-verify@1.1.2" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:block-no-verify\" \"scripts/hooks/block-no-verify.js\" \"standard,strict\"" } ], "description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped", diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js new file mode 100644 index 00000000..1ac0bc42 --- /dev/null +++ b/scripts/hooks/block-no-verify.js @@ -0,0 +1,219 @@ +#!/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); +});