@sdsrs/code-graph 0.153.0 → 0.154.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.153.0",
7
+ "version": "0.154.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -170,16 +170,22 @@ const SHELL_SAFE_ARG = /^[A-Za-z0-9_.,:@%+=/-]+$/;
170
170
  // POSIX single-quote escape: close, emit an escaped quote, reopen.
171
171
  const SQ_ESCAPED = "'\\''";
172
172
 
173
- function formatCgCommand(args) {
173
+ function shellQuoteArg(a) {
174
174
  // Deliberately no nested template literal, and no regex holding a quote.
175
175
  // windows-hide.test.js masks literals with a character scanner that pairs
176
176
  // backticks like any other quote, so a `${…`…`…}` inside a template closes the
177
177
  // outer one early and swallows the rest of the file — which took all three of
178
178
  // THIS file's spawn call sites out of that guard's view until it was written
179
179
  // this way. Code a guard cannot parse is code the guard is not checking.
180
- const shown = args.map((a) =>
181
- (SHELL_SAFE_ARG.test(a) ? a : "'" + a.split("'").join(SQ_ESCAPED) + "'"));
182
- return 'code-graph-mcp ' + shown.join(' ');
180
+ const s = String(a);
181
+ return SHELL_SAFE_ARG.test(s) ? s : "'" + s.split("'").join(SQ_ESCAPED) + "'";
182
+ }
183
+
184
+ // `invocation` is what names the binary. The default is the name a reader can
185
+ // paste; pre-grep-guide's rewrite passes an already-quoted absolute path when
186
+ // that name would not resolve in the Bash tool's shell.
187
+ function formatCgCommand(args, invocation = 'code-graph-mcp') {
188
+ return invocation + ' ' + args.map(shellQuoteArg).join(' ');
183
189
  }
184
190
 
185
191
  /**
@@ -356,6 +362,9 @@ function runShowAnswer(opts = {}) {
356
362
  if (!binary) return { status: 'no-binary' };
357
363
 
358
364
  const parts = [];
365
+ // Which symbols actually resolved — the rewrite re-runs exactly these, so a
366
+ // symbol that printed nothing here cannot turn into an exit-1 in the Bash call.
367
+ const resolved = [];
359
368
  for (const sym of symbols.slice(0, 3)) {
360
369
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
361
370
  const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
@@ -375,10 +384,11 @@ function runShowAnswer(opts = {}) {
375
384
  const out = (res.stdout || '').trim();
376
385
  if (isEmptyAnswer(out)) continue;
377
386
  parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
387
+ resolved.push(sym);
378
388
  }
379
389
  if (parts.length === 0) return { status: 'no-hits' };
380
390
  const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
381
- return { status: 'hits', text, truncated };
391
+ return { status: 'hits', text, truncated, symbols: resolved };
382
392
  } catch {
383
393
  return { status: 'unavailable' };
384
394
  }
@@ -498,7 +508,9 @@ module.exports = {
498
508
  truncateAtLine, sanitizeSearchPath,
499
509
  // The one argv builder + its renderer, so the deny copy and the child process
500
510
  // cannot describe different commands.
501
- splitSearchPathGlob, buildGrepArgs, formatCgCommand,
511
+ splitSearchPathGlob, buildGrepArgs, formatCgCommand, shellQuoteArg,
512
+ // pre-grep-guide names this binary in the command it rewrites a grep into.
513
+ resolveAnswerBinary,
502
514
  // Exported so the integer property can be asserted where it is ESTABLISHED.
503
515
  // Asserting it end-to-end instead passes either way: `remainingMs` floors
504
516
  // again downstream, so such a test cannot fail and proves nothing (NEW-02).
@@ -1393,9 +1393,9 @@ const DOCTOR_USAGE = [
1393
1393
  'By default doctor repairs detected issues (re-registers hooks in',
1394
1394
  '~/.claude/settings.json, fixes stale binary/model paths). Pass',
1395
1395
  '--check-only to report issues without repairing: it never writes',
1396
- '~/.claude/settings.json. Reaching the diagnostics still resolves',
1397
- 'the binary through the Node layer, which memoizes it under',
1398
- '~/.cache/code-graph/.',
1396
+ '~/.claude/settings.json. It is not otherwise write-free — the Node',
1397
+ 'layer memoizes the resolved binary under ~/.cache/code-graph/, and',
1398
+ 'the npm checks let npm write its own logs.',
1399
1399
  ].join('\n');
1400
1400
 
1401
1401
  function parseDoctorArgs(args) {
@@ -100,6 +100,44 @@ function emitPreToolAllowContext(text) {
100
100
  });
101
101
  }
102
102
 
103
+ /**
104
+ * PreToolUse rewrite envelope: allow + updatedInput + additionalContext.
105
+ *
106
+ * pre-grep-guide ONLY. A raw source grep the index can answer is rewritten into
107
+ * the `code-graph-mcp grep`/`show` command that answers it, instead of being
108
+ * denied with the answer in the reason. A deny is rendered as a failed tool call
109
+ * (the red `Error` block every intercepted grep used to print), and the model
110
+ * reads it as one; a rewrite runs as an ordinary successful call.
111
+ *
112
+ * Why `allow` rather than no decision. Claude Code 2.1.282 honours
113
+ * `updatedInput` WITHOUT a decision too (pre-ship review reproduced it; an
114
+ * earlier version of this comment said otherwise). But then the rewritten call
115
+ * goes through the permission flow as a command the user never approved: a user
116
+ * who allowlisted `grep` and not this binary would be prompted on every
117
+ * intercepted search — worse than the red deny this replaces. `"ask"` prompts
118
+ * by definition. So this carries `allow`, argued the same way as
119
+ * pre-read-guide's: the command that runs is one this hook assembled from a
120
+ * fixed argv (read-only `grep`/`show`, every argument shell-quoted, the binary
121
+ * by absolute path), never the model's text. The docs state deny and ask rules
122
+ * are still evaluated against the rewritten input.
123
+ *
124
+ * `updatedInput` REPLACES the tool input, so the caller passes the whole object.
125
+ * `reason` is shown to the user, not the model; `context` reaches the model.
126
+ * @param {{updatedInput: object, reason: string, context: string}} opts
127
+ * @returns {string} JSON line
128
+ */
129
+ function emitPreToolRewrite({ updatedInput, reason, context }) {
130
+ return JSON.stringify({
131
+ hookSpecificOutput: {
132
+ hookEventName: 'PreToolUse',
133
+ permissionDecision: 'allow',
134
+ permissionDecisionReason: reason,
135
+ updatedInput,
136
+ additionalContext: capContext(context),
137
+ },
138
+ });
139
+ }
140
+
103
141
  /**
104
142
  * PostToolUse additionalContext envelope (string, no trailing newline).
105
143
  * Permission-neutral: NO permissionDecision, so the underlying Bash tool call's
@@ -117,6 +155,6 @@ function emitPostToolContext(text) {
117
155
  }
118
156
 
119
157
  module.exports = {
120
- emitPreToolContext, emitPreToolAllowContext, emitPostToolContext,
158
+ emitPreToolContext, emitPreToolAllowContext, emitPreToolRewrite, emitPostToolContext,
121
159
  capContext, MAX_INJECTED_BYTES,
122
160
  };
@@ -14,6 +14,10 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Pr
14
14
  // ~13× more than the indexed CLI on bash-heavy days (15-day baseline: 429 raw
15
15
  // grep vs 191 functional CLI). v0.25.0 hint-only had ~0% transfer rate; v0.32.0
16
16
  // upgrades the narrowest "I'm searching for a symbol" subset to block-with-reason.
17
+ // Since the rewrite change, an ANSWERED block no longer denies: the grep is
18
+ // rewritten (PreToolUse `updatedInput`) into the cg command that answers it, so
19
+ // the call succeeds instead of rendering as a red failed tool call. Only the
20
+ // opt-in static deny (CODE_GRAPH_NO_ANSWER_IN_DENY=1) still denies.
17
21
  //
18
22
  // HINT fires when ALL conditions met (shouldHint):
19
23
  // 1. Command HEAD is grep/rg/ag (NOT piped — pipe-greps are output filters)
@@ -48,7 +52,11 @@ const fs = require('fs');
48
52
  const path = require('path');
49
53
  const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
50
54
  const { recordRecommendation } = require('./recommendation-log');
51
- const { runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand } = require('./cg-answer');
55
+ const {
56
+ runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand, shellQuoteArg,
57
+ resolveAnswerBinary,
58
+ } = require('./cg-answer');
59
+ const { emitPreToolRewrite } = require('./hook-emit');
52
60
 
53
61
  // --- Pure logic (testable) ---
54
62
 
@@ -758,56 +766,245 @@ function buildBlockReason() {
758
766
  ].join('\n');
759
767
  }
760
768
 
761
- // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
762
- // deny still asks the model to initiate a new tool call; embedding the actual
763
- // results removes that choice entirely.
764
- // v0.48 — NO escape-hatch line here: the model already has the results, and
765
- // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
766
- // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
767
- // escape because there we give no answer.
768
- // `cmdShown` is rendered by the caller from the SAME argv array it handed the
769
- // child process (cg-answer.buildGrepArgs → formatCgCommand). It used to be
770
- // re-assembled here from `(pattern, searchPath)` alone, which is how a deny came
771
- // to print `code-graph-mcp grep "X" tests` above an answer that had actually run
772
- // with different arguments — the flags nobody forwarded, and a glob silently
773
- // widened to its parent directory.
774
- function buildBlockReasonWithAnswer(cmdShown, answer) {
775
- const lines = [
776
- '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
777
- `$ ${cmdShown}`,
778
- answer.text,
779
- ];
780
- if (answer.truncated) {
781
- lines.push(`(truncated — run \`${cmdShown}\` yourself for the full list)`);
782
- }
783
- lines.push(
784
- 'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
785
- );
786
- // NOTE (v0.63): a forced-ack salience line was trialed here and removed. The
787
- // answer is ALREADY in context, so demanding the model restate which hit it
788
- // will use risks performative compliance + friction with no measured gain
789
- // (deny-with-answer already satisfies in-place, 5/5 in the daagu replay). The
790
- // engagement nudge is kept only where it pays — the pre-edit impact summary,
791
- // a true before-you-edit reconciliation moment. Re-add here only behind an A/B.
769
+ // Answered interceptions are REWRITES, not denies. Until this release the hook
770
+ // ran the cg equivalent itself and denied the grep with the output in the
771
+ // reason. It worked — the model used the answer — but Claude Code renders every
772
+ // deny as a failed tool call, so each intercepted search printed a red `Error`
773
+ // block over a perfectly good result. Now the grep is replaced (PreToolUse
774
+ // `updatedInput`) by the command whose output it would have embedded, and the
775
+ // call runs as an ordinary success. The hook still runs the answer first: it is
776
+ // what tells an answerable grep (rewrite) from a dialect miss or a broken binary
777
+ // (let the raw grep run), and a rewrite must never turn into an empty result.
778
+ //
779
+ // v0.48/v0.63 copy rules still hold for the context line: no escape-hatch
780
+ // advertisement (one deny once taught a 14-grep bypass prefix) and no forced
781
+ // restatement of which hit the model will use.
782
+
783
+ // The command the grep is rewritten into. One argv per cg call (several for a
784
+ // multi-symbol `show`), each run with CODE_GRAPH_INTERNAL=1 so the CLI's `use`
785
+ // record does not count a delivered answer as a model-initiated conversion —
786
+ // per command, because a `;`-joined list would scope a bare prefix to the first
787
+ // one only, and `export` would leak into the persistent shell. Paths in the argv
788
+ // are root-relative, so a shell sitting in a subdirectory runs them from the
789
+ // root in a subshell, leaving its own cwd alone.
790
+ //
791
+ // `invocation` is the binary that ANSWERED, by quoted absolute path — runMain
792
+ // passes it. Pre-ship review of the first cut: naming it `code-graph-mcp` let
793
+ // the Bash shell resolve a different, older copy (exit 2 on `-g`, a red error
794
+ // again) or a non-executable match (exit 127), bypassing the version gate
795
+ // `findBinary` exists for. The bare default is for the printed copy only.
796
+ function buildRewriteCommand(argvList, { invocation = 'code-graph-mcp', root, shellCwd } = {}) {
797
+ const body = argvList
798
+ .map((args) => 'CODE_GRAPH_INTERNAL=1 ' + formatCgCommand(args, invocation))
799
+ .join('; echo; ');
800
+ if (!root || !shellCwd || path.resolve(shellCwd) === path.resolve(root)) return body;
801
+ return '(cd ' + shellQuoteArg(root) + ' || exit 1; ' + body + ')';
802
+ }
803
+
804
+ // What the model is told alongside the rewritten call's output. `cmdShown` is
805
+ // rendered from the SAME argv the rewrite runs (no env prefix, bare name — the
806
+ // form a reader re-runs).
807
+ function buildRewriteContext(mode, cmdShown) {
808
+ const lines = mode === 'show'
809
+ ? ['[code-graph] Raw grep for symbol definitions on indexed source was rewritten to `code-graph-mcp show` — this call\'s output is the definitions from the AST index:',
810
+ `$ ${cmdShown}`,
811
+ 'Use these directly instead of re-running the search.']
812
+ : ['[code-graph] Raw `grep` on indexed source was rewritten to its AST-aware equivalent — this call\'s output comes from:',
813
+ `$ ${cmdShown}`,
814
+ 'Each hit shows its containing fn/module — use these results directly instead of re-running the search.'];
792
815
  return lines.join('\n');
793
816
  }
794
817
 
795
- // v0.49 — show-mode deny: the model grepped for symbol DEFINITIONS with
796
- // context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
797
- // fetched via `code-graph-mcp show`. answer.text already carries per-symbol
798
- // `$ code-graph-mcp show <sym>` headers.
799
- function buildShowDenyReason(answer) {
800
- const lines = [
801
- '[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
802
- answer.text,
803
- ];
804
- if (answer.truncated) {
805
- lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
818
+ // A rewrite REPLACES the whole command and reports success, so it is only
819
+ // honest when the cg call does everything the command would have done. Three
820
+ // rounds of pre-ship review each found a way past the previous gate: a
821
+ // side-effecting pipe stage, a command after `|| …` on the next line, flags a
822
+ // denylist did not name, then a redirect in a flag's value slot, filters whose
823
+ // hit count did not survive cg's output format, and globs cg reads differently
824
+ // from the shell. So the accepted shape is deliberately NARROW — a command
825
+ // outside it is not intercepted and runs as typed, which also shows no red
826
+ // block; narrowing costs interception, never the user's command:
827
+ //
828
+ // (grep | rg | ag | git grep) ARG… [2>&1 | 2>/dev/null]
829
+ //
830
+ // (`2>/dev/null` parses, but countNamedPaths counts it as a path, so it
831
+ // reaches a rewrite only when the command names no other path.)
832
+ //
833
+ // Every ARG is a flag from the verb's allowlist (values, where a flag takes
834
+ // one, are checked like any other word) or a plain/quoted operand: exactly one
835
+ // pattern and at most one path. Not accepted: any pipe, any other redirect, a
836
+ // NAME=value or `env` prefix (RIPGREP_CONFIG_PATH, GREP_OPTIONS, LC_ALL change
837
+ // the search), a glob in a path or an unquoted pattern, a pattern starting with
838
+ // `-`, and anything the tokenizer does not know — newline, `;`, `&`, `\`, `$`,
839
+ // a backtick, parentheses, braces, `~`. Returns a plan or null.
840
+ const WORD_CHAR = /[A-Za-z0-9_.,:@%+=/*?<>&-]/;
841
+ function shellWords(s) {
842
+ const words = [];
843
+ let cur = null;
844
+ const push = () => { if (cur) words.push(cur); cur = null; };
845
+ const start = (quoted) => { if (!cur) cur = { text: '', startsQuoted: quoted, anyQuoted: false, bareSpecial: false, bareOp: false }; };
846
+ for (let i = 0; i < s.length;) {
847
+ const c = s[i];
848
+ if (c === ' ' || c === '\t') { push(); i++; continue; }
849
+ if (c === '|') { push(); words.push({ op: '|' }); i++; continue; }
850
+ if (c === "'" || c === '"') {
851
+ const j = s.indexOf(c, i + 1);
852
+ if (j === -1) return null;
853
+ const body = s.slice(i + 1, j);
854
+ // Inside double quotes the shell still expands `$`/backticks, and a
855
+ // backslash escapes `"` `\` `$` backtick — so the span may not be what
856
+ // it looks like. A backslash before anything else is literal (`"a\|b"`,
857
+ // the BRE alternation models write constantly).
858
+ if (c === '"' && (/[$`!]|\\["\\$`]/.test(body) || body.endsWith('\\'))) return null;
859
+ start(true);
860
+ cur.anyQuoted = true;
861
+ cur.text += body;
862
+ i = j + 1;
863
+ continue;
864
+ }
865
+ if (!WORD_CHAR.test(c)) return null;
866
+ start(false);
867
+ if ('<>&*?'.includes(c)) cur.bareSpecial = true;
868
+ // Per character, independent of quoting elsewhere in the word: `-g'!x'>out`
869
+ // is one word whose quoted part must not excuse the redirect after it
870
+ // (pre-ship review round 4 M1).
871
+ if ('<>&'.includes(c)) cur.bareOp = true;
872
+ cur.text += c;
873
+ i++;
806
874
  }
807
- lines.push('Use these directly instead of re-running the search.');
808
- // NOTE (v0.63): forced-ack salience trialed + removed here too — see
809
- // buildBlockReasonWithAnswer. The definitions are already delivered.
810
- return lines.join('\n');
875
+ push();
876
+ return words;
877
+ }
878
+
879
+ // Flags cg honors (`-i -w -F -l -c`, extractCgFlags) or that are no-ops for it
880
+ // (recursion, line numbers, filenames, binary skipping, the regex dialect).
881
+ // Per verb, because letters differ: ag's `-n` is --norecurse and `-H` is
882
+ // --heading, rg's `-r` is --replace. Context letters (A/B/C, a count value)
883
+ // only reach the rewrite in `show` mode, which answers with the body.
884
+ const ALLOWED_SHORT = { grep: 'rRnHsIiwFlcEPABC', git: 'rnHIiwFlcEPABC', rg: 'nHiwFlcsABC', ag: 'iwlcsABC' };
885
+ const VALUE_SHORT_BY_VERB = { grep: 'ABC', git: 'ABC', rg: 'gtABC', ag: 'ABC' };
886
+ const COMMON_LONG = ['ignore-case', 'word-regexp', 'fixed-strings', 'files-with-matches', 'count', 'line-number'];
887
+ const ALLOWED_LONG = {
888
+ grep: new Set([...COMMON_LONG, 'recursive', 'with-filename', 'extended-regexp', 'perl-regexp', 'no-messages', 'include']),
889
+ git: new Set([...COMMON_LONG, 'extended-regexp', 'perl-regexp']),
890
+ rg: new Set([...COMMON_LONG, 'with-filename', 'no-heading', 'glob', 'type']),
891
+ ag: new Set(['ignore-case', 'word-regexp', 'files-with-matches', 'count', 'case-sensitive']),
892
+ };
893
+ const VALUE_LONG = new Set(['include', 'glob', 'type']);
894
+
895
+ // A flag's value word gets the same scrutiny as any other word: round 3 put
896
+ // `&`/`>` in the `-A 3&` slot and dropped a command. Counts are digits; a glob
897
+ // value is fine only quoted (unquoted, the shell would expand it first).
898
+ function valueWordOk(w, isCount) {
899
+ if (!w || w.op) return false;
900
+ if (isCount) return !w.anyQuoted && /^\d+$/.test(w.text);
901
+ return !(w.bareSpecial);
902
+ }
903
+
904
+ function rewritePlan(cmd) {
905
+ if (!cmd || typeof cmd !== 'string' || cmd.length > 1000) return null;
906
+ const words = shellWords(cmd);
907
+ if (!words || words.some((w) => w.op)) return null;
908
+ let i = 0;
909
+ let verb = !words[0].anyQuoted ? words[0].text : '';
910
+ if (verb === 'git') {
911
+ if (!words[1] || words[1].anyQuoted || words[1].text !== 'grep') return null;
912
+ i = 1;
913
+ } else if (!['grep', 'rg', 'ag'].includes(verb)) {
914
+ return null;
915
+ }
916
+ i++;
917
+ const allowed = ALLOWED_SHORT[verb];
918
+ const valueShort = VALUE_SHORT_BY_VERB[verb];
919
+ const operands = [];
920
+ let caseFlag = false;
921
+ let context = false;
922
+ let endOfFlags = false;
923
+ for (; i < words.length; i++) {
924
+ const w = words[i];
925
+ if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
926
+ if (w.startsQuoted || endOfFlags || w.text[0] !== '-' || w.text === '-') {
927
+ operands.push(w);
928
+ continue;
929
+ }
930
+ if (w.anyQuoted || w.bareSpecial) {
931
+ // Only an ATTACHED file-filter value may be quoted or carry a glob
932
+ // (`--include='*.rs'`, rg `-g'*.rs'`); an unquoted `<>&` is shell syntax.
933
+ const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
934
+ if (!attachedFilter || w.bareOp) return null;
935
+ // GNU grep's --include has no `!` negation; cg's -g does (round 4 L2).
936
+ if (/^--include=!/.test(w.text)) return null;
937
+ }
938
+ if (w.text === '--') { endOfFlags = true; continue; }
939
+ if (w.text.startsWith('--')) {
940
+ const eq = w.text.indexOf('=');
941
+ const name = w.text.slice(2, eq === -1 ? undefined : eq);
942
+ if (!ALLOWED_LONG[verb].has(name)) return null;
943
+ if (VALUE_LONG.has(name) && eq === -1) {
944
+ const v = words[++i];
945
+ if (!valueWordOk(v, false) || (name === 'include' && v.text.startsWith('!'))) return null;
946
+ }
947
+ if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
948
+ continue;
949
+ }
950
+ const letters = w.text.slice(1);
951
+ for (let k = 0; k < letters.length; k++) {
952
+ const ch = letters[k];
953
+ if (valueShort.includes(ch)) {
954
+ const isCount = 'ABC'.includes(ch);
955
+ if (isCount) context = true;
956
+ const attached = letters.slice(k + 1);
957
+ if (attached) {
958
+ if (isCount && !/^\d+$/.test(attached)) return null;
959
+ } else if (!valueWordOk(words[++i], isCount)) {
960
+ return null;
961
+ }
962
+ break;
963
+ }
964
+ if (!allowed.includes(ch)) return null;
965
+ if (ch === 'i' || ch === 's') caseFlag = true;
966
+ }
967
+ }
968
+ if (operands.length === 0 || operands.length > 2) return null;
969
+ const [pattern, target] = operands;
970
+ // The pattern: unquoted glob characters would be expanded by the shell, and
971
+ // a leading `-` would reach cg as flags.
972
+ if ((!pattern.anyQuoted && pattern.bareSpecial) || pattern.text.startsWith('-')) return null;
973
+ // The path: bash expands a glob at one level with no dotfiles; cg's `-g`
974
+ // matches recursively and includes them (round 3 M5). Not reproducible.
975
+ if (target && (/[*?[\]{}]/.test(target.text) || target.bareSpecial)) return null;
976
+ // A `..` segment: extractSearchPath refuses to scope it, so the answer would
977
+ // search the whole repo (round 4 M2).
978
+ if (target && /(?:^|\/)\.\.(?:\/|$)/.test(target.text)) return null;
979
+ // ag is smart-case by default: an all-lowercase pattern matches any case,
980
+ // and cg's search is case-sensitive, so the answer would find less.
981
+ if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
982
+ return { pattern: pattern.text, target: target ? target.text : undefined, context };
983
+ }
984
+
985
+ // The plan must describe the same search classifyBlock decided on. The pattern
986
+ // the answer ran is picked from quoted spans (pickBlockPattern); the grammar's
987
+ // pattern operand is the one the shell passes — `"is"'Foo'` or an unquoted
988
+ // pattern with a quoted path made them differ (round 3 L3). A context count the
989
+ // raw-text CONTEXT_FLAG check missed (`-A"3"`) sent a context grep to grep mode,
990
+ // which drops the context (L2). `show` answers with bodies, so a grep asking
991
+ // for a file list or counts (`-l`/`-c`) is not its equivalent (M6).
992
+ function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
993
+ if (!plan || !block) return false;
994
+ if (plan.pattern !== rawPattern) return false;
995
+ // Same for the path: extractSearchPath takes the first src-prefixed token,
996
+ // which can be a quoted PATTERN (`grep -rn "src/foo_mod" tmp/` searched
997
+ // src/foo_mod — round 4 H1, inherited from the deny's answer).
998
+ const norm = (p) => (p === undefined ? undefined : p.replace(/^\.\//, '').replace(/\/+$/, ''));
999
+ if (norm(plan.target) !== norm(extractSearchPath(cmd))) return false;
1000
+ if (block.mode === 'grep' && plan.context) return false;
1001
+ if (block.mode === 'show') {
1002
+ const f = cgFlagSet(extractCgFlags(cmd));
1003
+ if (f.has('-l') || f.has('-c')) return false;
1004
+ // show answers at most three symbols; a fourth would silently vanish.
1005
+ if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
1006
+ }
1007
+ return true;
811
1008
  }
812
1009
 
813
1010
  // v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
@@ -979,6 +1176,11 @@ function runMain() {
979
1176
  // switch silencing the hook that carries the telemetry is coherent; silently
980
1177
  // claiming coverage it does not have is not.
981
1178
  const block = isBlockDisabled() ? null : classifyDeny(cmd);
1179
+ // The rewrite replaces the WHOLE command. One it cannot reproduce runs as
1180
+ // typed (see rewritePlan) — decided before the answer is spent on it. The
1181
+ // opt-in static deny keeps its own, older scope.
1182
+ const plan = block && !isAnswerDisabled() ? rewritePlan(cmd) : null;
1183
+ if (block && !isAnswerDisabled() && !rewriteMatchesBlock(plan, block, cmd, rawGrepPattern)) return;
982
1184
  if (block) {
983
1185
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
984
1186
  // results in the deny reason ("answer in the deny"). Degrades to the
@@ -996,14 +1198,13 @@ function runMain() {
996
1198
  const flags = cgFlags; // computed once above, beside the -F pattern guard
997
1199
  // ONE argv, used to run the child AND to render the command the deny prints.
998
1200
  const args = buildGrepArgs({ pattern, searchPath, flags });
999
- let answeredMode = block.mode;
1201
+ const answeredMode = block.mode;
1000
1202
  if (!isAnswerDisabled()) {
1001
1203
  if (block.mode === 'show') {
1204
+ // No fallback to a grep answer: a context grep (`-A5`) asked for the
1205
+ // body, and the grep rewrite would drop the context silently (pre-ship
1206
+ // review round 2). A show miss lets the raw grep run.
1002
1207
  answer = runShowAnswer({ cwd: root, symbols: block.symbols });
1003
- if (answer.status !== 'hits' && pattern) {
1004
- answeredMode = 'grep';
1005
- answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
1006
- }
1007
1208
  } else if (pattern) {
1008
1209
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
1009
1210
  }
@@ -1035,35 +1236,62 @@ function runMain() {
1035
1236
  return;
1036
1237
  }
1037
1238
 
1038
- // PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`).
1039
- // Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was
1040
- // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
1041
- // is the documented modern path. Exit 0 — this is a routing decision, not
1042
- // a hook failure (exit 2 would mark the tool call as "hook errored").
1043
1239
  const answered = answer.status === 'hits';
1044
1240
  recordRecommendation(root, {
1241
+ // Still `deny` in the funnel: the event it counts — a raw grep intercepted
1242
+ // and answered in place — is unchanged, and the Rust aggregator keys on
1243
+ // it. `delivery` says HOW the answer arrived.
1045
1244
  hook: 'grep', action: 'deny', answered,
1046
1245
  // pattern fingerprints the denied search so the funnel can score a verbatim
1047
1246
  // re-grep of it (the inline answer was ignored) as fall-through, not a win.
1048
1247
  ...(pattern ? { pattern } : {}),
1049
1248
  // mode segments which answer type converts (show=bodies, grep=hits).
1050
- ...(answered ? { mode: answeredMode } : {}),
1249
+ ...(answered ? { mode: answeredMode, delivery: 'rewrite' } : {}),
1051
1250
  // reason segments WHY an unanswered deny fell back to the static copy:
1052
1251
  // 'no-binary' (flagship answer-in-deny dark — binary missing) vs
1053
1252
  // 'unavailable' (binary ran but failed/timed out). Without this the two
1054
1253
  // are indistinguishable in the funnel ("broken" looks like "no hits").
1055
1254
  ...(answered ? {} : { reason: answer.status }),
1056
1255
  });
1057
- process.stdout.write(JSON.stringify({
1058
- hookSpecificOutput: {
1059
- hookEventName: 'PreToolUse',
1060
- permissionDecision: 'deny',
1061
- permissionDecisionReason: !answered
1062
- ? buildBlockReason()
1063
- : answeredMode === 'show'
1064
- ? buildShowDenyReason(answer)
1065
- : buildBlockReasonWithAnswer(formatCgCommand(args), answer),
1066
- },
1256
+
1257
+ if (!answered) {
1258
+ // CODE_GRAPH_NO_ANSWER_IN_DENY=1 — the user opted into the v0.46 static
1259
+ // deny, so nothing ran and there is nothing to rewrite into. Current CC
1260
+ // schema (`hookSpecificOutput.permissionDecision`): the legacy
1261
+ // `{decision:"block"}` was ignored (verified 2026-05-24). Exit 0 — a
1262
+ // routing decision, not a hook failure.
1263
+ process.stdout.write(JSON.stringify({
1264
+ hookSpecificOutput: {
1265
+ hookEventName: 'PreToolUse',
1266
+ permissionDecision: 'deny',
1267
+ permissionDecisionReason: buildBlockReason(),
1268
+ },
1269
+ }) + '\n');
1270
+ return;
1271
+ }
1272
+
1273
+ // Re-run exactly what answered: the resolved symbols for `show`, the same
1274
+ // argv for `grep`.
1275
+ // `-m 0`: cg caps matches at 100 per file by default and says so only on
1276
+ // stderr; the grep being replaced has no cap (round 3 M4). Only here, not
1277
+ // in the in-hook answer, which is a has-hits probe.
1278
+ const argvList = answeredMode === 'show'
1279
+ ? answer.symbols.map((sym) => ['show', sym])
1280
+ : [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
1281
+ const command = buildRewriteCommand(argvList, {
1282
+ invocation: shellQuoteArg(resolveAnswerBinary({})),
1283
+ root,
1284
+ shellCwd,
1285
+ });
1286
+ const cmdShown = argvList.map((a) => formatCgCommand(a)).join('; ');
1287
+ // Not carried over: a model-set `dangerouslyDisableSandbox`. The call this
1288
+ // hook auto-allows is its own read-only cg command, and it needs no escape
1289
+ // from a sandbox the model's grep would have run inside.
1290
+ const { dangerouslyDisableSandbox, ...toolInput } = input.tool_input || {};
1291
+ process.stdout.write(emitPreToolRewrite({
1292
+ updatedInput: { ...toolInput, command },
1293
+ reason: '[code-graph] raw grep → AST-aware equivalent',
1294
+ context: buildRewriteContext(answeredMode, cmdShown),
1067
1295
  }) + '\n');
1068
1296
  return;
1069
1297
  }
@@ -1093,7 +1321,11 @@ module.exports = {
1093
1321
  firstShellClause, // v0.96 — grep's own clause (up to first top-level separator)
1094
1322
  extractDeclSymbols, // v0.49 — show-mode symbol extraction
1095
1323
  translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
1096
- buildShowDenyReason, // v0.49 — show-mode deny copy
1324
+ buildRewriteCommand, // rewrite — the command the grep becomes
1325
+ buildRewriteContext, // rewrite — what the model is told about it
1326
+ shellWords, // rewrite — the tokenizer rewritePlan's grammar reads
1327
+ rewritePlan, // rewrite — does the whole command parse as one the rewrite reproduces
1328
+ rewriteMatchesBlock, // rewrite — does that plan describe the search classifyBlock chose
1097
1329
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
1098
1330
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
1099
1331
  extractPatterns, // v0.32.1 — exposed for tests
@@ -1107,7 +1339,6 @@ module.exports = {
1107
1339
  pickBlockPattern,
1108
1340
  buildHint,
1109
1341
  buildBlockReason,
1110
- buildBlockReasonWithAnswer,
1111
1342
  buildNoHitsFyi,
1112
1343
  buildUnavailableFyi, // v0.92 — allow-on-unavailable breadcrumb
1113
1344
  commandHash,
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.153.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.154.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.153.0",
3
+ "version": "0.154.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.153.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.153.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.153.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.153.0",
42
- "@sdsrs/code-graph-win32-x64": "0.153.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.154.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.154.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.154.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.154.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.154.0"
43
43
  }
44
44
  }