From 8ccf1e8808deccdc4142bcb20c1d1eb28abfd77c Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Thu, 11 Jun 2026 02:55:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20grep=20guard=20matches=20absolut?= =?UTF-8?q?e=20paths=20=E2=80=94=20was=20missing=20~97%=20of=20real=20traf?= =?UTF-8?q?fic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SRC_PATH's lookbehind (^|\s|quote) never matched absolute paths, but the CC harness explicitly steers Bash toward them. Field replay (daagu, 2026-06-11, 3 real coding sessions): 42/42 head-greps absolute -> 1 hint / 0 block as-is vs 30 hint / 16 block with this fix. v0.47.0's answer-in-the-deny was unreachable on consumer projects until now. Fix: normalizeCommandPaths strips / (hook cwd IS the project root) before shouldHint/shouldBlock/extractSearchPath; split/join not regex (cwd may contain metacharacters). Paths outside the project stay absolute and keep not firing. Cooldown still keyed on the raw command. Evidence: 10 new tests RED->GREEN incl. real-transcript replays in both spellings + abs-path e2e asserting relative CLI argv (131/131). Full 42-command daagu corpus replay: hint 1->30, block 0->16. Live daagu smoke: abs-path config_version grep -> deny embedding real hits with containing fn, answered:true; residue cleaned. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 +++++ claude-plugin/scripts/pre-grep-guide.js | 25 +++++- claude-plugin/scripts/pre-grep-guide.test.js | 89 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd009fd..7992abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v0.47.1 — fix: grep guard now matches absolute paths (it was missing ~97% of real traffic) + +The deny/hint tier of `pre-grep-guide.js` only matched **relative** source paths +(`grep -rn "X" backend/app/…`), but Claude Code's harness explicitly steers Bash toward +**absolute** paths — so `grep -rn "X" /abs/project/backend/app/…`, the dominant real-world +shape, never fired. Field replay (daagu, 3 real coding sessions, 2026-06-11): 42/42 raw +greps used absolute paths → 1 hint / 0 blocks as-is vs **30 hints / 16 blocks** after this +fix. The v0.47.0 answer-in-the-deny feature was unreachable on consumer projects until now. + +- Fix: strip `/` (the hook's cwd IS the project root) from the command before + matching — absolute paths under the root now behave exactly like their relative + spelling; paths outside the project still never fire (conservative edge preserved). + The inline answer's CLI scope argument is passed in relative form. +- Replay methodology added to tests: real transcript commands asserted in both spellings. +- Known limitation (pre-existing, unchanged): the CLI `grep` shells to ripgrep, whose + gitignore handling can diverge from git on `dir/` + `!negation` whitelists (observed: + rg 14.1.0 prunes a git-whitelisted directory when walking from above). Worst case is + the honest no-hits fallthrough: the raw grep is allowed through and finds the truth. + ## v0.47.0 — feat: answer in the deny — denied greps now return the actual results **What changes for users**: when the PreToolUse hook denies a symbol-shaped raw grep, the diff --git a/claude-plugin/scripts/pre-grep-guide.js b/claude-plugin/scripts/pre-grep-guide.js index 5ca2298..9e2863e 100644 --- a/claude-plugin/scripts/pre-grep-guide.js +++ b/claude-plugin/scripts/pre-grep-guide.js @@ -117,6 +117,20 @@ function shouldBlock(cmd) { return patterns.some(p => IDENTIFIER_LIKE.test(p)); } +// v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound +// commands triggers permission prompts), so `grep -rn "X" /abs/root/backend/…` +// is the dominant real shape — and SRC_PATH's lookbehind (^|\s|quote) never +// matched it (daagu 2026-06-11 replay: 42/42 head-greps absolute → 1 hint / +// 0 block as-is vs 30 / 16 after this strip). Strip `/` everywhere before +// matching: the hook's cwd IS the project root, so this is exact — paths +// outside the project stay absolute and keep not firing (conservative edge). +// split/join, not regex: cwd may contain regex metacharacters. +function normalizeCommandPaths(cmd, cwd) { + if (!cmd || typeof cmd !== 'string') return cmd; + if (!cwd || typeof cwd !== 'string' || cwd === '/') return cmd; + return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join(''); +} + // v0.47.0 — pull the first source-tree path token out of the denied command so // the inline answer can scope its search the same way the raw grep would have. function extractSearchPath(cmd) { @@ -243,11 +257,15 @@ function runMain() { input = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { return; } - const cmd = (input.tool_input && input.tool_input.command) || ''; + const rawCmd = (input.tool_input && input.tool_input.command) || ''; + // v0.47.1 — match against the cwd-stripped form so absolute paths under the + // project root behave exactly like their relative spelling. Cooldown stays + // keyed on the raw command (what Claude actually sent). + const cmd = normalizeCommandPaths(rawCmd, cwd); if (!shouldHint(cmd)) return; - if (isOnCooldown(cmd)) return; + if (isOnCooldown(rawCmd)) return; - markCooldown(cmd); + markCooldown(rawCmd); if (!isBlockDisabled() && shouldBlock(cmd)) { // v0.47.0 — run the AST-aware equivalent inside the hook and embed the @@ -299,6 +317,7 @@ module.exports = { shouldBlock, extractPatterns, // v0.32.1 — exposed for tests extractSearchPath, // v0.47.0 — deny-with-answer + normalizeCommandPaths, // v0.47.1 — abs-path matcher fix pickBlockPattern, buildHint, buildBlockReason, diff --git a/claude-plugin/scripts/pre-grep-guide.test.js b/claude-plugin/scripts/pre-grep-guide.test.js index fc76b3c..79c53cb 100644 --- a/claude-plugin/scripts/pre-grep-guide.test.js +++ b/claude-plugin/scripts/pre-grep-guide.test.js @@ -6,6 +6,7 @@ const { shouldBlock, extractPatterns, extractSearchPath, + normalizeCommandPaths, pickBlockPattern, buildHint, buildBlockReason, @@ -530,6 +531,74 @@ test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => { assert.equal(shouldBlock('grep -rn "fn render" src/'), true); }); +// ── v0.47.1 abs-path matcher fix: normalizeCommandPaths ───────────── +// CC harness steers Bash toward ABSOLUTE paths (cd in compound commands +// triggers permission prompts), so `grep -rn "X" /abs/root/backend/...` is +// the dominant real-world shape. SRC_PATH's lookbehind (^|\s|quote) never +// matched it: daagu 2026-06-11 replay — 42/42 head-greps absolute, 1 hint / +// 0 block as-is vs 30 hint / 16 block after cwd-strip. + +test('normalizeCommandPaths: strips cwd prefix from path args', () => { + assert.equal( + normalizeCommandPaths('grep -rn "X" /proj/root/src/storage/', '/proj/root'), + 'grep -rn "X" src/storage/'); +}); + +test('normalizeCommandPaths: strips every occurrence', () => { + assert.equal( + normalizeCommandPaths('grep -rn "X" /proj/root/src/a.rs /proj/root/tests/', '/proj/root'), + 'grep -rn "X" src/a.rs tests/'); +}); + +test('normalizeCommandPaths: strips inside quotes', () => { + assert.equal( + normalizeCommandPaths('grep -rn "X" "/proj/root/backend/app/"', '/proj/root'), + 'grep -rn "X" "backend/app/"'); +}); + +test('normalizeCommandPaths: leaves foreign absolute paths alone', () => { + assert.equal( + normalizeCommandPaths('grep -rn "X" /other/place/src/', '/proj/root'), + 'grep -rn "X" /other/place/src/'); +}); + +test('normalizeCommandPaths: no-op when cwd absent / falsy inputs', () => { + assert.equal(normalizeCommandPaths('grep -rn "X" src/', '/proj/root'), 'grep -rn "X" src/'); + assert.equal(normalizeCommandPaths('', '/proj/root'), ''); + assert.equal(normalizeCommandPaths('grep "X" src/', ''), 'grep "X" src/'); +}); + +// Real daagu transcript commands (2026-06-11 session 23f149f0…), the exact +// shape that was invisible to v0.47.0. Replay must fire post-normalization. +const DAAGU = '/mnt/data_ssd/dev/projects/daagu'; + +test('replay: real abs-path symbol grep → BLOCK after normalization', () => { + const cmd = `grep -n "_parse_finish_reason\\|_last_finish_reason\\|class OpenRouterProvider" ${DAAGU}/backend/app/services/llm_engine/openrouter.py`; + assert.equal(shouldHint(cmd), false); // documents the v0.47.0 blindspot + const norm = normalizeCommandPaths(cmd, DAAGU); + assert.equal(shouldHint(norm), true); + assert.equal(shouldBlock(norm), true); +}); + +test('replay: real abs-path -rln grep → HINT only after normalization (precision flag)', () => { + const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`; + const norm = normalizeCommandPaths(cmd, DAAGU); + assert.equal(shouldHint(norm), true); + assert.equal(shouldBlock(norm), false); // -l cluster disqualifies block +}); + +test('replay: abs-path config-only grep stays silent after normalization', () => { + const cmd = `grep -n '"typecheck"\\|"type-check"\\|vue-tsc' ${DAAGU}/frontend/package.json`; + assert.equal(shouldHint(normalizeCommandPaths(cmd, DAAGU)), false); +}); + +test('replay: extractSearchPath gets relative path from normalized abs command', () => { + const cmd = `grep -rn "config_version" ${DAAGU}/backend/app/services/stock_picker/data_providers.py 2>/dev/null | head -5`; + assert.equal( + extractSearchPath(normalizeCommandPaths(cmd, DAAGU)), + 'backend/app/services/stock_picker/data_providers.py'); +}); + // ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ── test('extractSearchPath: dir path after pattern', () => { @@ -724,6 +793,26 @@ test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false' } }); +test('e2e: ABS-path grep under fixture root → deny fires, CLI argv gets relative path', () => { + const uniq = `StubAbs${Date.now()}`; + const fixture = e2eFixture( + `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`); + // fs.realpathSync: on macOS/Linux tmpdir may be a symlink; the hook sees the + // resolved cwd, so build the command from the same resolved form. + const realDir = fsE2e.realpathSync(fixture.dir); + const cmd = `grep -rn "${uniq}" ${realDir}/src/storage/`; + try { + const res = runHook(cmd, fixture); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny'); + assert.match(out.hookSpecificOutput.permissionDecisionReason, + /args=\["grep","StubAbs\d+","src\/storage\/"\]/); + } finally { + cleanupFixture(fixture, cmd); + } +}); + test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => { const uniq = `StubOptout${Date.now()}`; const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);