@sdsrs/code-graph 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.47.0",
7
+ "version": "0.48.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -48,6 +48,24 @@ function truncateAtLine(text, maxBytes) {
48
48
  return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
49
49
  }
50
50
 
51
+ /**
52
+ * v0.48 — drop glob segments from a search path. The hook extracts path tokens
53
+ * verbatim from the denied command, and spawnSync runs WITHOUT a shell, so a
54
+ * literal `backend/…/llm_engine/*.py` reaches rg as a nonexistent file →
55
+ * exit 1 → `unavailable` → static deny with no answer (daagu 2026-06-11: the
56
+ * night's only deny failed exactly this way). Truncate at the first segment
57
+ * containing a glob metacharacter; widening the scope to the parent dir is
58
+ * always safe. A leading glob (`*.py`) drops the scope entirely (repo-wide).
59
+ */
60
+ function sanitizeSearchPath(searchPath) {
61
+ if (!searchPath || typeof searchPath !== 'string') return undefined;
62
+ const segs = searchPath.split('/');
63
+ const i = segs.findIndex((s) => /[*?[\]{}]/.test(s));
64
+ if (i === -1) return searchPath;
65
+ const kept = segs.slice(0, i).join('/');
66
+ return kept || undefined;
67
+ }
68
+
51
69
  /**
52
70
  * Run `code-graph-mcp grep <pattern> [searchPath]` synchronously.
53
71
  *
@@ -81,8 +99,11 @@ function runGrepAnswer(opts = {}) {
81
99
  }
82
100
  if (!binary) return { status: 'unavailable' };
83
101
 
102
+ // Defensive re-sanitize: callers should pass a clean path, but a glob
103
+ // reaching argv is a guaranteed nonzero exit (see sanitizeSearchPath).
104
+ const scope = sanitizeSearchPath(searchPath);
84
105
  const args = ['grep', pattern];
85
- if (searchPath) args.push(searchPath);
106
+ if (scope) args.push(scope);
86
107
  const res = spawnSync(binary, args, {
87
108
  cwd,
88
109
  timeout: timeoutMs,
@@ -104,4 +125,4 @@ function runGrepAnswer(opts = {}) {
104
125
  }
105
126
  }
106
127
 
107
- module.exports = { runGrepAnswer, truncateAtLine };
128
+ module.exports = { runGrepAnswer, truncateAtLine, sanitizeSearchPath };
@@ -133,3 +133,30 @@ test('truncateAtLine: single oversized line → hard cut', () => {
133
133
  assert.equal(truncated, true);
134
134
  assert.equal(Buffer.byteLength(text, 'utf8'), 10);
135
135
  });
136
+
137
+ // ── v0.48 sanitizeSearchPath: glob args reach rg literally (no shell) ──
138
+
139
+ test('sanitizeSearchPath: truncates at first glob segment (daagu denied command)', () => {
140
+ const { sanitizeSearchPath } = require('./cg-answer');
141
+ assert.equal(
142
+ sanitizeSearchPath('backend/app/services/llm_engine/*.py'),
143
+ 'backend/app/services/llm_engine');
144
+ });
145
+
146
+ test('sanitizeSearchPath: clean path unchanged; leading glob drops scope; falsy → undefined', () => {
147
+ const { sanitizeSearchPath } = require('./cg-answer');
148
+ assert.equal(sanitizeSearchPath('src/storage/'), 'src/storage/');
149
+ assert.equal(sanitizeSearchPath('*.py'), undefined);
150
+ assert.equal(sanitizeSearchPath('src/**/x.rs'), 'src');
151
+ assert.equal(sanitizeSearchPath('src/file[1].rs'), 'src');
152
+ assert.equal(sanitizeSearchPath(''), undefined);
153
+ assert.equal(sanitizeSearchPath(undefined), undefined);
154
+ });
155
+
156
+ test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)', () => {
157
+ const r = runGrepAnswer({
158
+ cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/*.rs', binary: stubBinary(),
159
+ });
160
+ assert.equal(r.status, 'hits');
161
+ assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
162
+ });
@@ -12,7 +12,10 @@
12
12
  // 2. Args include an indexed source-tree path (src/ tests/ lib/ scripts/ ...)
13
13
  // 3. Not searching only a config/lockfile (Cargo.toml/.gitignore/*.md/*.json)
14
14
  // 4. Command doesn't already invoke code-graph-mcp (no double-suggest)
15
- // 5. .code-graph/index.db exists in CWD
15
+ // 5. .code-graph/index.db exists in CWD or a parent up to $HOME (v0.48: the
16
+ // hook's cwd follows the persistent shell — after `cd backend/` every
17
+ // gate used to fail silently for the rest of the session; daagu
18
+ // 2026-06-11 replay: 38/40 head-greps dark to this)
16
19
  // 6. Same command-hash not hinted within last 60s (per-command cooldown)
17
20
  //
18
21
  // BLOCK fires when shouldHint AND (shouldBlock):
@@ -22,19 +25,28 @@
22
25
  // 9. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
23
26
  // 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS)
24
27
  //
28
+ // A `CODE_GRAPH_NO_BLOCK_GREP=1`-prefixed grep that would otherwise hint is
29
+ // recorded as `action:'bypass'` and allowed silently (v0.48) — previously the
30
+ // bare KEY=VALUE prefix failed GREP_HEAD and the escape was invisible to the
31
+ // conversion funnel (daagu 2026-06-11: 14 bypassed greps, 0 recorded).
32
+ //
25
33
  // Exits silently otherwise — zero noise for build greps, log filters, config
26
34
  // lookups, or the rare legitimate use of raw grep on indexed source.
27
35
 
28
36
  const fs = require('fs');
37
+ const os = require('os');
29
38
  const path = require('path');
30
39
  const crypto = require('crypto');
31
40
  const { cgTmpDir } = require('./tmp-dir');
32
41
  const { recordRecommendation } = require('./recommendation-log');
33
- const { runGrepAnswer } = require('./cg-answer');
42
+ const { runGrepAnswer, sanitizeSearchPath } = require('./cg-answer');
34
43
 
35
44
  // --- Pure logic (testable) ---
36
45
 
37
- const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/;
46
+ // v0.48: also match bare `KEY=VALUE grep` prefixes (no `env` verb) — the shape
47
+ // the deny message itself teaches (`CODE_GRAPH_NO_BLOCK_GREP=1 grep …`). With
48
+ // the old `env`-only form those commands failed gate 1 and were invisible.
49
+ const GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(grep|rg|ag)\b/;
38
50
  // Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
39
51
  // missed real-world backend conventions where the prefix list term is preceded
40
52
  // by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
@@ -99,8 +111,8 @@ const MARKER_ONLY =
99
111
  // symbol-shaped target".
100
112
  function extractPatterns(cmd) {
101
113
  if (!cmd || typeof cmd !== 'string') return [];
102
- // Strip leading verb + env prefix
103
- const stripped = cmd.replace(/^\s*(?:env\s+\S+=\S+\s+)*(?:grep|rg|ag)\s+/, '');
114
+ // Strip leading verb + env/assignment prefix (kept in sync with GREP_HEAD)
115
+ const stripped = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
104
116
  // Collect every quoted argument — first one is the pattern in standard grep
105
117
  // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
106
118
  // expressions and worth screening too.
@@ -117,6 +129,83 @@ function shouldBlock(cmd) {
117
129
  return patterns.some(p => IDENTIFIER_LIKE.test(p));
118
130
  }
119
131
 
132
+ // v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound
133
+ // commands triggers permission prompts), so `grep -rn "X" /abs/root/backend/…`
134
+ // is the dominant real shape — and SRC_PATH's lookbehind (^|\s|quote) never
135
+ // matched it (daagu 2026-06-11 replay: 42/42 head-greps absolute → 1 hint /
136
+ // 0 block as-is vs 30 / 16 after this strip). Strip `<cwd>/` everywhere before
137
+ // matching: the hook's cwd IS the project root, so this is exact — paths
138
+ // outside the project stay absolute and keep not firing (conservative edge).
139
+ // split/join, not regex: cwd may contain regex metacharacters.
140
+ function normalizeCommandPaths(cmd, cwd) {
141
+ if (!cmd || typeof cmd !== 'string') return cmd;
142
+ if (!cwd || typeof cwd !== 'string' || cwd === '/') return cmd;
143
+ return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
144
+ }
145
+
146
+ // v0.48 — the hook's process.cwd() follows the PERSISTENT shell, not the
147
+ // project root: after the model runs `cd backend/`, every later bare grep used
148
+ // to fail the index.db gate silently for the rest of the session (daagu
149
+ // 2026-06-11: 38/40 head-greps dark). Walk up to the nearest ancestor holding
150
+ // `.code-graph/index.db`; stop at $HOME (checked, not crossed) and fs root.
151
+ function resolveProjectRoot(startDir, opts = {}) {
152
+ const home = opts.home !== undefined ? opts.home : os.homedir();
153
+ const exists = opts.exists || fs.existsSync;
154
+ let dir = path.resolve(startDir || '.');
155
+ for (;;) {
156
+ if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
157
+ if (dir === home) return null;
158
+ const parent = path.dirname(dir);
159
+ if (parent === dir) return null;
160
+ dir = parent;
161
+ }
162
+ }
163
+
164
+ // v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
165
+ // bare relative path args (`app --include=*.py` from backend/) are
166
+ // subdir-relative and never match the root-relative SRC_PATH prefixes. Rebase
167
+ // each candidate token onto the project root; a token only counts as a path
168
+ // when the rebased form EXISTS under the root — quoted patterns, flags,
169
+ // operators, absolute and traversal tokens are never touched. Existence is the
170
+ // workhorse gate: it keeps unquoted pattern words from masquerading as paths
171
+ // (the exact shape that would re-create the answered:false glob failure).
172
+ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
173
+ if (!cmd || typeof cmd !== 'string' || !relPrefix || !rootDir) return cmd;
174
+ const prefix = relPrefix.split(path.sep).join('/');
175
+ // Shell sits outside any known source dir (docs/, target/, …) — don't guess.
176
+ if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
177
+ let verbSeen = false;
178
+ return cmd.split(/(\s+)/).map((tok) => {
179
+ if (!tok || /^\s+$/.test(tok)) return tok;
180
+ if (!verbSeen) {
181
+ if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
182
+ verbSeen = true; // the verb itself (grep/rg/ag) — never a path
183
+ return tok;
184
+ }
185
+ if (/^["']/.test(tok)) return tok; // quoted → pattern
186
+ if (tok.startsWith('-')) return tok; // flag
187
+ if (tok.startsWith('/')) return tok; // absolute (foreign — root strip already ran)
188
+ if (tok.includes('..')) return tok; // traversal
189
+ if (/[|;&<>=\\$`'"]/.test(tok)) return tok; // operators / redirects / assignments / escapes
190
+ const candidate = prefix + '/' + tok;
191
+ // Probe existence on the glob-truncated form: `app/…/llm_engine/*.py`
192
+ // must still rebase (its dir exists) or the deny-answer would run a
193
+ // subdir-relative path from the root and fail (answered:false again).
194
+ const probe = sanitizeSearchPath(candidate);
195
+ try {
196
+ if (!probe || !exists(path.join(rootDir, probe))) return tok;
197
+ } catch { return tok; }
198
+ return candidate;
199
+ }).join('');
200
+ }
201
+
202
+ // v0.48 — bypass detection on the RAW command. The deny message documents
203
+ // `CODE_GRAPH_NO_BLOCK_GREP=1` as a per-command escape; record its use so the
204
+ // conversion funnel can see escape adoption instead of going dark.
205
+ function commandHasBypass(cmd) {
206
+ return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
207
+ }
208
+
120
209
  // v0.47.0 — pull the first source-tree path token out of the denied command so
121
210
  // the inline answer can scope its search the same way the raw grep would have.
122
211
  function extractSearchPath(cmd) {
@@ -174,14 +263,18 @@ function buildBlockReason() {
174
263
  ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
175
264
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
176
265
  ' code-graph-mcp callgraph SYMBOL # callers + callees',
177
- 'For raw-text scans (log/comment/marker), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
266
+ 'If this specific search truly needs raw-text regex (log/comment scan), prepend',
267
+ '`CODE_GRAPH_NO_BLOCK_GREP=1` to THIS command only — a per-command escape, not a default prefix.',
178
268
  ].join('\n');
179
269
  }
180
270
 
181
271
  // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
182
272
  // deny still asks the model to initiate a new tool call; embedding the actual
183
- // results removes that choice entirely. Keep the escape hatch line — raw-text
184
- // regex (BRE alternation, log scans) remains a legitimate need.
273
+ // results removes that choice entirely.
274
+ // v0.48 NO escape-hatch line here: the model already has the results, and
275
+ // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
276
+ // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
277
+ // escape because there we give no answer.
185
278
  function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
186
279
  const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
187
280
  const lines = [
@@ -194,7 +287,6 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
194
287
  }
195
288
  lines.push(
196
289
  'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
197
- 'For raw-text regex (alternation, log/comment scans), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
198
290
  );
199
291
  return lines.join('\n');
200
292
  }
@@ -231,9 +323,11 @@ function isAnswerDisabled(env = process.env) {
231
323
 
232
324
  function runMain() {
233
325
  if (isSilenced()) return;
234
- const cwd = process.cwd();
235
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
236
- if (!fs.existsSync(dbPath)) return; // no index — no hint
326
+ // v0.48 process.cwd() follows the persistent shell; resolve the project
327
+ // root by walking up so `cd backend/` no longer darkens the whole session.
328
+ const shellCwd = process.cwd();
329
+ const root = resolveProjectRoot(shellCwd);
330
+ if (root === null) return; // no index anywhere up to $HOME — no hint
237
331
 
238
332
  let input;
239
333
  try {
@@ -243,11 +337,26 @@ function runMain() {
243
337
  input = JSON.parse(fs.readFileSync(0, 'utf8'));
244
338
  } catch { return; }
245
339
 
246
- const cmd = (input.tool_input && input.tool_input.command) || '';
340
+ const rawCmd = (input.tool_input && input.tool_input.command) || '';
341
+ // v0.47.1 — match against the root-stripped form so absolute paths under the
342
+ // project root behave exactly like their relative spelling. v0.48 — then
343
+ // rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
344
+ // the raw command (what Claude actually sent).
345
+ let cmd = normalizeCommandPaths(rawCmd, root);
346
+ const relPrefix = path.relative(root, shellCwd);
347
+ if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
247
348
  if (!shouldHint(cmd)) return;
248
- if (isOnCooldown(cmd)) return;
249
349
 
250
- markCooldown(cmd);
350
+ // v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
351
+ // Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
352
+ if (commandHasBypass(rawCmd)) {
353
+ recordRecommendation(root, { hook: 'grep', action: 'bypass' });
354
+ return;
355
+ }
356
+
357
+ if (isOnCooldown(rawCmd)) return;
358
+
359
+ markCooldown(rawCmd);
251
360
 
252
361
  if (!isBlockDisabled() && shouldBlock(cmd)) {
253
362
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
@@ -256,12 +365,15 @@ function runMain() {
256
365
  // (regex-dialect differences mean 0 hits ≠ proof of absence).
257
366
  let answer = { status: 'unavailable' };
258
367
  const pattern = pickBlockPattern(cmd);
368
+ // v0.48 — glob-truncated once, shared by the run and the deny message
369
+ // (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
370
+ const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
259
371
  if (!isAnswerDisabled() && pattern) {
260
- answer = runGrepAnswer({ cwd, pattern, searchPath: extractSearchPath(cmd) });
372
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
261
373
  }
262
374
 
263
375
  if (answer.status === 'no-hits') {
264
- recordRecommendation(cwd, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
376
+ recordRecommendation(root, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
265
377
  process.stdout.write(buildNoHitsFyi(pattern) + '\n');
266
378
  return;
267
379
  }
@@ -271,7 +383,7 @@ function runMain() {
271
383
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
272
384
  // is the documented modern path. Exit 0 — this is a routing decision, not
273
385
  // a hook failure (exit 2 would mark the tool call as "hook errored").
274
- recordRecommendation(cwd, {
386
+ recordRecommendation(root, {
275
387
  hook: 'grep', action: 'deny', answered: answer.status === 'hits',
276
388
  });
277
389
  process.stdout.write(JSON.stringify({
@@ -279,14 +391,14 @@ function runMain() {
279
391
  hookEventName: 'PreToolUse',
280
392
  permissionDecision: 'deny',
281
393
  permissionDecisionReason: answer.status === 'hits'
282
- ? buildBlockReasonWithAnswer(pattern, extractSearchPath(cmd), answer)
394
+ ? buildBlockReasonWithAnswer(pattern, searchPath, answer)
283
395
  : buildBlockReason(),
284
396
  },
285
397
  }) + '\n');
286
398
  return;
287
399
  }
288
400
 
289
- recordRecommendation(cwd, { hook: 'grep', action: 'hint' });
401
+ recordRecommendation(root, { hook: 'grep', action: 'hint' });
290
402
  process.stdout.write(buildHint() + '\n');
291
403
  }
292
404
 
@@ -299,6 +411,10 @@ module.exports = {
299
411
  shouldBlock,
300
412
  extractPatterns, // v0.32.1 — exposed for tests
301
413
  extractSearchPath, // v0.47.0 — deny-with-answer
414
+ normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
415
+ resolveProjectRoot, // v0.48 — subdir-cwd dark fix
416
+ rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
417
+ commandHasBypass, // v0.48 — bypass funnel visibility
302
418
  pickBlockPattern,
303
419
  buildHint,
304
420
  buildBlockReason,
@@ -6,6 +6,10 @@ const {
6
6
  shouldBlock,
7
7
  extractPatterns,
8
8
  extractSearchPath,
9
+ normalizeCommandPaths,
10
+ resolveProjectRoot,
11
+ rebaseRelativePaths,
12
+ commandHasBypass,
9
13
  pickBlockPattern,
10
14
  buildHint,
11
15
  buildBlockReason,
@@ -530,6 +534,74 @@ test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => {
530
534
  assert.equal(shouldBlock('grep -rn "fn render" src/'), true);
531
535
  });
532
536
 
537
+ // ── v0.47.1 abs-path matcher fix: normalizeCommandPaths ─────────────
538
+ // CC harness steers Bash toward ABSOLUTE paths (cd in compound commands
539
+ // triggers permission prompts), so `grep -rn "X" /abs/root/backend/...` is
540
+ // the dominant real-world shape. SRC_PATH's lookbehind (^|\s|quote) never
541
+ // matched it: daagu 2026-06-11 replay — 42/42 head-greps absolute, 1 hint /
542
+ // 0 block as-is vs 30 hint / 16 block after cwd-strip.
543
+
544
+ test('normalizeCommandPaths: strips cwd prefix from path args', () => {
545
+ assert.equal(
546
+ normalizeCommandPaths('grep -rn "X" /proj/root/src/storage/', '/proj/root'),
547
+ 'grep -rn "X" src/storage/');
548
+ });
549
+
550
+ test('normalizeCommandPaths: strips every occurrence', () => {
551
+ assert.equal(
552
+ normalizeCommandPaths('grep -rn "X" /proj/root/src/a.rs /proj/root/tests/', '/proj/root'),
553
+ 'grep -rn "X" src/a.rs tests/');
554
+ });
555
+
556
+ test('normalizeCommandPaths: strips inside quotes', () => {
557
+ assert.equal(
558
+ normalizeCommandPaths('grep -rn "X" "/proj/root/backend/app/"', '/proj/root'),
559
+ 'grep -rn "X" "backend/app/"');
560
+ });
561
+
562
+ test('normalizeCommandPaths: leaves foreign absolute paths alone', () => {
563
+ assert.equal(
564
+ normalizeCommandPaths('grep -rn "X" /other/place/src/', '/proj/root'),
565
+ 'grep -rn "X" /other/place/src/');
566
+ });
567
+
568
+ test('normalizeCommandPaths: no-op when cwd absent / falsy inputs', () => {
569
+ assert.equal(normalizeCommandPaths('grep -rn "X" src/', '/proj/root'), 'grep -rn "X" src/');
570
+ assert.equal(normalizeCommandPaths('', '/proj/root'), '');
571
+ assert.equal(normalizeCommandPaths('grep "X" src/', ''), 'grep "X" src/');
572
+ });
573
+
574
+ // Real daagu transcript commands (2026-06-11 session 23f149f0…), the exact
575
+ // shape that was invisible to v0.47.0. Replay must fire post-normalization.
576
+ const DAAGU = '/mnt/data_ssd/dev/projects/daagu';
577
+
578
+ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
579
+ const cmd = `grep -n "_parse_finish_reason\\|_last_finish_reason\\|class OpenRouterProvider" ${DAAGU}/backend/app/services/llm_engine/openrouter.py`;
580
+ assert.equal(shouldHint(cmd), false); // documents the v0.47.0 blindspot
581
+ const norm = normalizeCommandPaths(cmd, DAAGU);
582
+ assert.equal(shouldHint(norm), true);
583
+ assert.equal(shouldBlock(norm), true);
584
+ });
585
+
586
+ test('replay: real abs-path -rln grep → HINT only after normalization (precision flag)', () => {
587
+ const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
588
+ const norm = normalizeCommandPaths(cmd, DAAGU);
589
+ assert.equal(shouldHint(norm), true);
590
+ assert.equal(shouldBlock(norm), false); // -l cluster disqualifies block
591
+ });
592
+
593
+ test('replay: abs-path config-only grep stays silent after normalization', () => {
594
+ const cmd = `grep -n '"typecheck"\\|"type-check"\\|vue-tsc' ${DAAGU}/frontend/package.json`;
595
+ assert.equal(shouldHint(normalizeCommandPaths(cmd, DAAGU)), false);
596
+ });
597
+
598
+ test('replay: extractSearchPath gets relative path from normalized abs command', () => {
599
+ const cmd = `grep -rn "config_version" ${DAAGU}/backend/app/services/stock_picker/data_providers.py 2>/dev/null | head -5`;
600
+ assert.equal(
601
+ extractSearchPath(normalizeCommandPaths(cmd, DAAGU)),
602
+ 'backend/app/services/stock_picker/data_providers.py');
603
+ });
604
+
533
605
  // ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ──
534
606
 
535
607
  test('extractSearchPath: dir path after pattern', () => {
@@ -582,17 +654,30 @@ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
582
654
 
583
655
  // ── v0.47.0 deny-with-answer: message builders + env gate ───────────
584
656
 
585
- test('buildBlockReasonWithAnswer: embeds results, command, and escape hatch', () => {
657
+ test('buildBlockReasonWithAnswer: embeds results and command', () => {
586
658
  const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
587
659
  status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
588
660
  });
589
661
  assert.match(reason, /already ran/);
590
662
  assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
591
663
  assert.match(reason, /src\/storage\/db\.rs:42/);
592
- assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
593
664
  assert.doesNotMatch(reason, /truncated/);
594
665
  });
595
666
 
667
+ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one deny taught a 14-grep permanent prefix)', () => {
668
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
669
+ status: 'hits', text: 'hit', truncated: false,
670
+ });
671
+ assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
672
+ });
673
+
674
+ test('buildBlockReason: scopes the escape to THIS command only', () => {
675
+ const reason = buildBlockReason();
676
+ assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
677
+ assert.match(reason, /THIS command only/);
678
+ assert.match(reason, /not a default/);
679
+ });
680
+
596
681
  test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
597
682
  const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
598
683
  status: 'hits', text: 'hit', truncated: false,
@@ -637,9 +722,9 @@ function e2eFixture(stubBody) {
637
722
  return { dir, stub };
638
723
  }
639
724
 
640
- function runHook(cmd, fixture) {
725
+ function runHook(cmd, fixture, cwdOverride) {
641
726
  const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
642
- cwd: fixture.dir,
727
+ cwd: cwdOverride || fixture.dir,
643
728
  input: JSON.stringify({ tool_input: { command: cmd } }),
644
729
  encoding: 'utf8',
645
730
  env: {
@@ -724,6 +809,26 @@ test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false'
724
809
  }
725
810
  });
726
811
 
812
+ test('e2e: ABS-path grep under fixture root → deny fires, CLI argv gets relative path', () => {
813
+ const uniq = `StubAbs${Date.now()}`;
814
+ const fixture = e2eFixture(
815
+ `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
816
+ // fs.realpathSync: on macOS/Linux tmpdir may be a symlink; the hook sees the
817
+ // resolved cwd, so build the command from the same resolved form.
818
+ const realDir = fsE2e.realpathSync(fixture.dir);
819
+ const cmd = `grep -rn "${uniq}" ${realDir}/src/storage/`;
820
+ try {
821
+ const res = runHook(cmd, fixture);
822
+ assert.equal(res.status, 0);
823
+ const out = JSON.parse(res.stdout);
824
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
825
+ assert.match(out.hookSpecificOutput.permissionDecisionReason,
826
+ /args=\["grep","StubAbs\d+","src\/storage\/"\]/);
827
+ } finally {
828
+ cleanupFixture(fixture, cmd);
829
+ }
830
+ });
831
+
727
832
  test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => {
728
833
  const uniq = `StubOptout${Date.now()}`;
729
834
  const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
@@ -748,3 +853,190 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
748
853
  cleanupFixture(fixture, cmd);
749
854
  }
750
855
  });
856
+
857
+ // ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
858
+ // daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
859
+ // head-greps for the rest of the night — gate 5 checked process.cwd() only.
860
+
861
+ const { sanitizeSearchPath } = require('./cg-answer');
862
+
863
+ test('resolveProjectRoot: index at start dir', () => {
864
+ const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
865
+ try {
866
+ fsE2e.mkdirSync(pathE2e.join(base, 'proj', '.code-graph'), { recursive: true });
867
+ fsE2e.writeFileSync(pathE2e.join(base, 'proj', '.code-graph', 'index.db'), '');
868
+ assert.equal(
869
+ resolveProjectRoot(pathE2e.join(base, 'proj'), { home: base }),
870
+ pathE2e.join(base, 'proj'));
871
+ } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
872
+ });
873
+
874
+ test('resolveProjectRoot: walks up from nested subdir to the indexed root', () => {
875
+ const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
876
+ try {
877
+ const proj = pathE2e.join(base, 'proj');
878
+ fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
879
+ fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
880
+ const deep = pathE2e.join(proj, 'backend', 'app', 'services');
881
+ fsE2e.mkdirSync(deep, { recursive: true });
882
+ assert.equal(resolveProjectRoot(deep, { home: base }), proj);
883
+ } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
884
+ });
885
+
886
+ test('resolveProjectRoot: no index up to $HOME → null (home itself still checked)', () => {
887
+ const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
888
+ try {
889
+ const deep = pathE2e.join(base, 'somewhere', 'deep');
890
+ fsE2e.mkdirSync(deep, { recursive: true });
891
+ assert.equal(resolveProjectRoot(deep, { home: base }), null);
892
+ // home itself holding an index is honored
893
+ fsE2e.mkdirSync(pathE2e.join(base, '.code-graph'), { recursive: true });
894
+ fsE2e.writeFileSync(pathE2e.join(base, '.code-graph', 'index.db'), '');
895
+ assert.equal(resolveProjectRoot(deep, { home: base }), base);
896
+ } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
897
+ });
898
+
899
+ test('rebaseRelativePaths: daagu shape — bare `app` from backend/ cwd', () => {
900
+ const exists = (p) => p.endsWith(pathE2e.join('backend', 'app'));
901
+ const cmd = 'grep -rn "rr_source\\|max_retries" app --include=*.py';
902
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
903
+ assert.equal(rebased, 'grep -rn "rr_source\\|max_retries" backend/app --include=*.py');
904
+ assert.equal(shouldHint(rebased), true);
905
+ assert.equal(extractSearchPath(rebased), 'backend/app');
906
+ });
907
+
908
+ test('rebaseRelativePaths: deep relPrefix, multiple file args', () => {
909
+ const exists = (p) => p.endsWith('.py');
910
+ const rel = 'backend/app/services/scheduler/tasks';
911
+ const cmd = 'grep -n "except Exception" asr_preload.py xuanlun_pro_scan.py';
912
+ const rebased = rebaseRelativePaths(cmd, rel, '/proj', exists);
913
+ assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/asr_preload\.py/);
914
+ assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/xuanlun_pro_scan\.py/);
915
+ assert.equal(shouldHint(rebased), true);
916
+ });
917
+
918
+ test('rebaseRelativePaths: quoted patterns are never rebased even if a same-named path exists', () => {
919
+ const exists = () => true; // adversarial: everything "exists"
920
+ const cmd = 'grep -rn "retry" app';
921
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
922
+ assert.equal(rebased, 'grep -rn "retry" backend/app');
923
+ });
924
+
925
+ test('rebaseRelativePaths: flags, absolute, traversal, operators untouched', () => {
926
+ const exists = () => true;
927
+ const cmd = 'grep -rn "X" /etc/hosts ../up --include=*.py 2>/dev/null';
928
+ assert.equal(rebaseRelativePaths(cmd, 'backend', '/proj', exists), cmd);
929
+ });
930
+
931
+ test('rebaseRelativePaths: non-source relPrefix (docs/) → unchanged', () => {
932
+ const exists = () => true;
933
+ const cmd = 'grep -rn "X" app';
934
+ assert.equal(rebaseRelativePaths(cmd, 'docs', '/proj', exists), cmd);
935
+ });
936
+
937
+ test('rebaseRelativePaths: unquoted pattern word does not exist → untouched', () => {
938
+ const exists = (p) => p.endsWith('/backend/app');
939
+ const cmd = 'grep -rn retry_count app';
940
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
941
+ assert.equal(rebased, 'grep -rn retry_count backend/app');
942
+ });
943
+
944
+ // ── v0.48 bypass visibility: GREP_HEAD bare-prefix + commandHasBypass ──
945
+
946
+ test('shouldHint: bare KEY=VALUE prefixed grep now matches GREP_HEAD', () => {
947
+ assert.equal(shouldHint('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "fts5_search" src/'), true);
948
+ });
949
+
950
+ test('extractPatterns: bare KEY=VALUE prefix stripped with the verb', () => {
951
+ assert.deepEqual(
952
+ extractPatterns('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "split_identifier" src/'),
953
+ ['split_identifier']);
954
+ });
955
+
956
+ test('commandHasBypass: =1 prefix detected, other values / absence are not', () => {
957
+ assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "X" src/'), true);
958
+ assert.equal(commandHasBypass('FOO=1 CODE_GRAPH_NO_BLOCK_GREP=1 grep "X" src/'), true);
959
+ assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=0 grep -rn "X" src/'), false);
960
+ assert.equal(commandHasBypass('grep -rn "CODE_GRAPH_NO_BLOCK_GREP=1" src/'), false);
961
+ assert.equal(commandHasBypass('grep -rn "X" src/'), false);
962
+ });
963
+
964
+ // ── v0.48 replay: the exact command behind the night's only deny ──
965
+ // (answered:false — glob path reached rg literally and exited 1)
966
+
967
+ test('replay: daagu denied glob command → block + sanitized search path', () => {
968
+ const cmd = 'grep -rn "async def chat\\|def chat\\|retry\\|rate.limit\\|rate-limit\\|RateLimit\\|429\\|max_retries\\|backoff\\|fallback_model\\|temporarily" backend/app/services/llm_engine/*.py | head -40';
969
+ assert.equal(shouldHint(cmd), true);
970
+ assert.equal(shouldBlock(cmd), true);
971
+ const raw = extractSearchPath(cmd);
972
+ assert.equal(raw, 'backend/app/services/llm_engine/*.py');
973
+ assert.equal(sanitizeSearchPath(raw), 'backend/app/services/llm_engine');
974
+ });
975
+
976
+ // ── v0.48 e2e: hook process spawned exactly as CC does ──
977
+
978
+ test('e2e: subdir cwd — hook resolves root, rebases path, records at root', () => {
979
+ const uniq = `sub_dir_fix_${Date.now()}`;
980
+ const fixture = e2eFixture(
981
+ `process.stdout.write('backend/app/x.py:1 fn hit()\\n');`);
982
+ const cmd = `grep -rn "${uniq}\\|max_retries" app`;
983
+ try {
984
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'backend', 'app'), { recursive: true });
985
+ const res = runHook(cmd, fixture, pathE2e.join(fixture.dir, 'backend'));
986
+ const out = JSON.parse(res.stdout);
987
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
988
+ const recs = fsE2e.readFileSync(
989
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
990
+ assert.match(recs, /"action":"deny"/);
991
+ // never creates .code-graph in the subdir
992
+ assert.equal(fsE2e.existsSync(pathE2e.join(fixture.dir, 'backend', '.code-graph')), false);
993
+ } finally {
994
+ cleanupFixture(fixture, cmd);
995
+ }
996
+ });
997
+
998
+ test('e2e: bypassed grep is silent but recorded as action:bypass', () => {
999
+ const uniq = `bypass_vis_${Date.now()}`;
1000
+ const fixture = e2eFixture(`process.stdout.write('never called\\n');`);
1001
+ const cmd = `CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "${uniq}\\|fts5_search" src/`;
1002
+ try {
1003
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1004
+ const res = runHook(cmd, fixture);
1005
+ assert.equal(res.stdout, '');
1006
+ const recs = fsE2e.readFileSync(
1007
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1008
+ assert.match(recs, /"action":"bypass"/);
1009
+ } finally {
1010
+ cleanupFixture(fixture, cmd);
1011
+ }
1012
+ });
1013
+
1014
+ test('e2e: glob path arg → answer runs against the glob-truncated dir', () => {
1015
+ const uniq = `GlobTrunc${Date.now()}`;
1016
+ const fixture = e2eFixture(
1017
+ `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
1018
+ const cmd = `grep -rn "${uniq}" src/storage/*.rs`;
1019
+ try {
1020
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src', 'storage'), { recursive: true });
1021
+ const res = runHook(cmd, fixture);
1022
+ const out = JSON.parse(res.stdout);
1023
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1024
+ assert.match(out.hookSpecificOutput.permissionDecisionReason,
1025
+ new RegExp(`args=\\["grep","${uniq}","src/storage"\\]`));
1026
+ } finally {
1027
+ cleanupFixture(fixture, cmd);
1028
+ }
1029
+ });
1030
+
1031
+ test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists', () => {
1032
+ // daagu shape: shell in backend/, command scopes a glob under it. Without
1033
+ // the truncated probe the token stayed subdir-relative while the answer ran
1034
+ // from the root → rg ENOENT → answered:false (the original night bug).
1035
+ const exists = (p) => p.endsWith(pathE2e.join('backend', 'app', 'services', 'llm_engine'));
1036
+ const cmd = 'grep -rn "def chat\\|max_retries" app/services/llm_engine/*.py';
1037
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
1038
+ assert.equal(
1039
+ extractSearchPath(rebased), 'backend/app/services/llm_engine/*.py');
1040
+ assert.equal(
1041
+ sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
1042
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.47.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.47.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.47.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.47.0",
42
- "@sdsrs/code-graph-win32-x64": "0.47.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.48.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.48.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.48.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.48.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.48.0"
43
43
  }
44
44
  }