From d8a84b5f7bc7a3cf19d3e5f68066c40682f8bc92 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Sun, 7 Jun 2026 10:31:36 +0530 Subject: [PATCH] fix(.cursor/hooks): route block-no-verify through local hook to fix message-body false positives (#2107) (#2177) Cursor hooks still called `npx block-no-verify@1.1.2`, the broken external package whose matcher over-matches: it blocks legitimate `git commit` whenever `--no-verify` (or `no-verify`) appears anywhere in the command string, including inside the commit message body. The Claude Code surface already routes through the in-repo `scripts/hooks/block-no-verify.js`, which performs flag-position-aware tokenisation and passes 25 regression tests covering every false-positive case from #2107. Add a thin Cursor wrapper (`before-shell-execution-block-no-verify.js`) that reads Cursor stdin, transforms to the Claude Code `tool_input.command` shape, delegates to the local hook's exported `run()`, and forwards exit code and stderr. Update `.cursor/hooks.json` to call the wrapper instead of the npx package. New 14-case test file pins the false-positive cases from the issue plus the still-blocked real bypass attempts. Fixes #2107 --- .cursor/hooks.json | 2 +- .../before-shell-execution-block-no-verify.js | 63 ++++++++ tests/hooks/cursor-block-no-verify.test.js | 147 ++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 .cursor/hooks/before-shell-execution-block-no-verify.js create mode 100644 tests/hooks/cursor-block-no-verify.test.js diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 573e647f..9c3f9d23 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -17,7 +17,7 @@ ], "beforeShellExecution": [ { - "command": "npx block-no-verify@1.1.2", + "command": "node .cursor/hooks/before-shell-execution-block-no-verify.js", "event": "beforeShellExecution", "description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped" }, diff --git a/.cursor/hooks/before-shell-execution-block-no-verify.js b/.cursor/hooks/before-shell-execution-block-no-verify.js new file mode 100644 index 00000000..4c9e07db --- /dev/null +++ b/.cursor/hooks/before-shell-execution-block-no-verify.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Cursor wrapper for block-no-verify. + * + * Cursor hooks previously called `npx block-no-verify@1.1.2`, an external + * package whose matcher over-matches: it blocks legitimate `git commit` + * whenever the literal string `--no-verify` (or `no-verify`) appears + * anywhere in the command string, including inside the commit message + * body. See issue #2107. + * + * The Claude Code surface already routes through the local, in-repo hook + * `scripts/hooks/block-no-verify.js`, which performs flag-position-aware + * tokenisation (skipping the value of `-m`, `-F`, `-am "..."`, etc.) and + * passes 25 regression tests covering every false-positive case. + * + * This wrapper gives Cursor the same matcher: read Cursor stdin, transform + * to the Claude Code `tool_input.command` shape the local hook understands, + * delegate to its exported `run()`, then forward the exit code and stderr. + */ + +'use strict'; + +const { readStdin, hookEnabled } = require('./adapter'); +const { run } = require('../../scripts/hooks/block-no-verify'); + +readStdin() + .then(raw => { + if (!hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) { + process.stdout.write(raw); + return; + } + + let command = ''; + try { + const parsed = JSON.parse(raw || '{}'); + command = String(parsed.command || parsed.args?.command || ''); + } catch { + command = String(raw || ''); + } + + // Local hook accepts either the raw command string or a Claude-Code + // shaped `{ tool_input: { command } }` JSON. Pass the Claude shape so + // the JSON branch in extractCommand() is exercised the same way the + // Claude Code surface exercises it — keeps the two surfaces on the + // same code path. + const claudeInput = JSON.stringify({ tool_input: { command } }); + const result = run(claudeInput); + + if (result && result.exitCode === 2) { + if (result.stderr) { + process.stderr.write(String(result.stderr) + '\n'); + } + process.exit(2); + } + + process.stdout.write(raw); + }) + .catch(() => { + // Per repo rule: hooks must exit 0 on non-critical errors and never + // unexpectedly block tool execution. A parse / transport error here + // is non-critical — fall through. + process.exit(0); + }); diff --git a/tests/hooks/cursor-block-no-verify.test.js b/tests/hooks/cursor-block-no-verify.test.js new file mode 100644 index 00000000..a1e7f127 --- /dev/null +++ b/tests/hooks/cursor-block-no-verify.test.js @@ -0,0 +1,147 @@ +/** + * Tests for .cursor/hooks/before-shell-execution-block-no-verify.js + * + * Issue #2107: previously .cursor/hooks.json wired `npx block-no-verify@1.1.2`, + * which over-matches and blocks legitimate commits whose message body + * mentions `--no-verify` or `-n`. The wrapper added in this PR delegates + * to the local scripts/hooks/block-no-verify.js so Cursor users get the + * same flag-position-aware matcher Claude Code already uses. + */ + +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const wrapper = path.join( + __dirname, '..', '..', + '.cursor', 'hooks', 'before-shell-execution-block-no-verify.js' +); + +function runWrapper(input, env = {}) { + const rawInput = typeof input === 'string' ? input : JSON.stringify(input); + const result = spawnSync('node', [wrapper], { + 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 || '', + }; +} + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +let passed = 0; +let failed = 0; + +console.log('\ncursor block-no-verify wrapper tests'); +console.log('─'.repeat(50)); + +// --- Cursor input shapes --- + +if (test('reads Cursor top-level command field', () => { + const r = runWrapper({ command: 'git commit -m "hello"' }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('reads Cursor args.command field', () => { + const r = runWrapper({ args: { command: 'git commit -m "hello"' } }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +// --- Issue #2107 false positives now allowed --- + +if (test('#2107: allows --no-verify mentioned in double-quoted message body', () => { + const r = runWrapper({ command: 'git commit -m "docs: explain why we never pass --no-verify"' }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('#2107: allows --no-verify mentioned in single-quoted message body', () => { + const r = runWrapper({ command: "git commit -m 'docs: discuss --no-verify risk'" }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('#2107: allows -n mentioned in quoted message body', () => { + const r = runWrapper({ command: 'git commit -m "fix: handle -n flag in parser"' }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('#2107: allows commit message containing the literal string "block-no-verify"', () => { + const r = runWrapper({ command: 'git commit -m "feat: add block-no-verify hook"' }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +// --- Real bypass attempts still blocked --- + +if (test('still blocks real --no-verify flag', () => { + const r = runWrapper({ command: 'git commit --no-verify -m "msg"' }); + assert.strictEqual(r.code, 2, `expected 2, got ${r.code}`); + assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`); +})) passed++; else failed++; + +if (test('still blocks -n shorthand on git commit', () => { + const r = runWrapper({ command: 'git commit -n -m "msg"' }); + assert.strictEqual(r.code, 2, `expected 2, got ${r.code}`); +})) passed++; else failed++; + +if (test('still blocks core.hooksPath override', () => { + const r = runWrapper({ command: 'git -c core.hooksPath=/dev/null commit -m "msg"' }); + assert.strictEqual(r.code, 2, `expected 2, got ${r.code}`); + assert.ok(r.stderr.includes('core.hooksPath'), `stderr should mention core.hooksPath: ${r.stderr}`); +})) passed++; else failed++; + +if (test('still blocks --no-verify on git push', () => { + const r = runWrapper({ command: 'git push --no-verify' }); + assert.strictEqual(r.code, 2, `expected 2, got ${r.code}`); +})) passed++; else failed++; + +// --- Pass-through cases --- + +if (test('allows non-git commands', () => { + const r = runWrapper({ command: 'npm test' }); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('handles empty stdin gracefully', () => { + const r = runWrapper(''); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('handles malformed JSON gracefully (treats raw as command string)', () => { + const r = runWrapper('git commit -m "hello"'); + assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +// --- Disable via ECC_DISABLED_HOOKS --- + +if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => { + const r = runWrapper( + { command: 'git commit --no-verify -m "msg"' }, + { ECC_DISABLED_HOOKS: 'pre:bash:block-no-verify' } + ); + // When the hook is disabled, the wrapper should pass through (exit 0) + // even on a real bypass attempt. + assert.strictEqual(r.code, 0, `expected 0 when disabled, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +console.log('─'.repeat(50)); +console.log(`Passed: ${passed} Failed: ${failed}`); + +process.exit(failed > 0 ? 1 : 0);