Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<cwd>/` (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
Expand Down
25 changes: 22 additions & 3 deletions claude-plugin/scripts/pre-grep-guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/` 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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions claude-plugin/scripts/pre-grep-guide.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
shouldBlock,
extractPatterns,
extractSearchPath,
normalizeCommandPaths,
pickBlockPattern,
buildHint,
buildBlockReason,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');`);
Expand Down
Loading