@sdsrs/code-graph 0.126.2 → 0.128.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.126.2",
7
+ "version": "0.128.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -700,6 +700,16 @@ async function downloadAndInstall(latest, {
700
700
  let pluginUpdated = false;
701
701
  let binaryUpdated = false;
702
702
  let marketplaceRefreshed = false;
703
+ // JS-02 (audit 2026-08-29): declared out here so it can be RETURNED. It used
704
+ // to live inside the `if (pluginUpdated)` block, correctly stopping the
705
+ // manifest from advancing, and then evaporate — the caller saw
706
+ // `pluginUpdated: true`, wrote `updateAttempts: 0, updateAvailable: false,
707
+ // suspendedAt: null`, and re-read the un-advanced registry next round. With
708
+ // `updateAvailable:false` the recheck interval is 30 minutes, so a registry
709
+ // that stays broken drives ~48 full download-and-install rounds a day —
710
+ // exactly the treadmill MAX_UPDATE_ATTEMPTS and the suspension mechanism
711
+ // exist to stop, both of which stayed dormant because every round "succeeded".
712
+ let repointBlocked = false;
703
713
 
704
714
  try {
705
715
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -808,7 +818,6 @@ async function downloadAndInstall(latest, {
808
818
  // behind, the ordinary check interval retries the whole install and
809
819
  // re-reports, and the repoint lands by itself the moment the file is
810
820
  // repaired. `missing` is not blocked: no registry means nothing to repoint.
811
- let repointBlocked = false;
812
821
  if (installedRead.corrupt) {
813
822
  // Value unusable — the bytes are not ours to guess at.
814
823
  const why = installedRead.error
@@ -848,7 +857,28 @@ async function downloadAndInstall(latest, {
848
857
  repointBlocked = true;
849
858
  }
850
859
  }
851
- if (installed && installed.plugins && installed.plugins[PLUGIN_ID]) {
860
+ // JS-05 (audit 2026-08-29): `plugins[PLUGIN_ID]` was assumed to be a
861
+ // non-empty array. A registry holding `[]` — or any other truthy
862
+ // non-array — made `[0].installPath = …` throw a TypeError, which the
863
+ // outer catch reported as "Plugin download/extract failed": a diagnosis
864
+ // pointing at the download for a malformed local file. `activeInstallPath`
865
+ // in lifecycle.js already reads this same field the careful way; this is
866
+ // the sibling that did not.
867
+ const records = installed && installed.plugins && installed.plugins[PLUGIN_ID];
868
+ const repointable =
869
+ Array.isArray(records) && records[0] && typeof records[0] === 'object';
870
+ if (installed && !repointable && installed.plugins && installed.plugins[PLUGIN_ID]) {
871
+ // Present but not the shape we can write into (`[]`, or a truthy
872
+ // non-array). Blocked, NOT skipped: a silent skip here would feed the
873
+ // JS-02 treadmill through a new door.
874
+ console.error(
875
+ `[code-graph] plugin ${latest.version} is installed, but this plugin's entry in ` +
876
+ `${installedPath} is malformed (expected a non-empty array) — it still points at ` +
877
+ 'the previous version. Run `/plugin update` or repair that file by hand.'
878
+ );
879
+ repointBlocked = true;
880
+ }
881
+ if (repointable) {
852
882
  installed.plugins[PLUGIN_ID][0].installPath = pluginDst;
853
883
  installed.plugins[PLUGIN_ID][0].version = latest.version;
854
884
  installed.plugins[PLUGIN_ID][0].lastUpdated = new Date().toISOString();
@@ -900,7 +930,7 @@ async function downloadAndInstall(latest, {
900
930
  binaryUpdated = true;
901
931
  }
902
932
 
903
- return { pluginUpdated, binaryUpdated, marketplaceRefreshed };
933
+ return { pluginUpdated, binaryUpdated, marketplaceRefreshed, repointBlocked };
904
934
  } catch (e) {
905
935
  console.error(`[code-graph] Plugin download/extract failed: ${e.message}`);
906
936
  return { pluginUpdated: false, binaryUpdated: false, marketplaceRefreshed };
@@ -1278,7 +1308,11 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1278
1308
  return { updateAvailable: true, suspended: true, from: installedVersion, to: latest.version };
1279
1309
  }
1280
1310
  const result = await downloadAndInstall(latest);
1281
- const success = result.pluginUpdated;
1311
+ // A refused repoint is NOT a success (JS-02): the bytes landed, but the
1312
+ // registry Claude Code reads still names the old version, so the next round
1313
+ // sees the same update available. Counting it as success reset the attempt
1314
+ // counter and the suspension stamp every time.
1315
+ const success = result.pluginUpdated && !result.repointBlocked;
1282
1316
  // Suspension clock. It restarts when the daily retry is spent and fails,
1283
1317
  // which is what keeps `retryDue` from staying true and turning the retry
1284
1318
  // back into a per-session treadmill; it clears on success and on a new
@@ -401,8 +401,21 @@ function findBinaryUncached() {
401
401
  if (process.env._FIND_BINARY_ROOT) {
402
402
  possibleRoots.add(path.resolve(process.env._FIND_BINARY_ROOT));
403
403
  }
404
- // From CLAUDE_PROJECT_DIR
405
- if (process.env.CLAUDE_PROJECT_DIR) {
404
+ // From CLAUDE_PROJECT_DIR — behind an explicit opt-in, and ONLY that.
405
+ //
406
+ // This root is the arbitrary directory the developer opened; the two above are
407
+ // derived from the plugin's own install. Accepting it made "clone an untrusted
408
+ // repo and open it" arbitrary code execution: this tier sits above the version
409
+ // gate, `isDevRepo` asks only whether a `Cargo.toml` exists, and resolution
410
+ // itself runs the file (`writeCacheEntry` → `readBinaryVersion`) — all before
411
+ // any file is read or any tool approved. A tracked, mode-755
412
+ // `target/release/code-graph-mcp` survives `.gitignore` into a fresh clone,
413
+ // so supplying one costs the attacker nothing.
414
+ //
415
+ // No property of the opened directory can re-establish trust here (an attacker
416
+ // supplies all of them), so the gate is the developer's own opt-in, spelled
417
+ // the same way `version-utils.js:isDevMode` spells it.
418
+ if (process.env.CLAUDE_PROJECT_DIR && process.env.CODE_GRAPH_DEV === '1') {
406
419
  possibleRoots.add(path.resolve(process.env.CLAUDE_PROJECT_DIR));
407
420
  }
408
421
 
@@ -639,9 +639,18 @@ function unadoptRegisteredProjects() {
639
639
  for (const project of (res && res.list) || []) {
640
640
  try {
641
641
  const r = unadopt({ cwd: project });
642
- out.push({ project, cleaned: !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved)) });
642
+ // Three outcomes, not two (audit 2026-08-29 JS-06). A project whose block
643
+ // the user already removed by hand comes back with nothing pruned and NO
644
+ // error — which used to land in the "Could NOT clean" list, sending them
645
+ // to hand-edit a file that is already clean. `unadopt` reports real
646
+ // failure separately (`claudeMdUnreadable` / `claudeMdUnwritable`, the
647
+ // same pair adopt.js folds into its own `cleanupFailed`), so use it rather
648
+ // than inferring failure from "nothing happened".
649
+ const cleaned = !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved));
650
+ const failed = !!(r && (r.claudeMdUnreadable || r.claudeMdUnwritable));
651
+ out.push({ project, cleaned, failed });
643
652
  } catch (e) {
644
- out.push({ project, cleaned: false, error: (e && e.message) || String(e) });
653
+ out.push({ project, cleaned: false, failed: true, error: (e && e.message) || String(e) });
645
654
  }
646
655
  }
647
656
  reportUnadoptSweep(out);
@@ -659,7 +668,9 @@ function unadoptRegisteredProjects() {
659
668
  function reportUnadoptSweep(entries) {
660
669
  try {
661
670
  const cleaned = entries.filter((e) => e && e.cleaned).map((e) => e.project);
662
- const failed = entries.filter((e) => e && !e.cleaned).map((e) => e.project);
671
+ // Only genuine failures. "Nothing to clean" is neither a success worth
672
+ // announcing nor a problem worth sending someone to fix (JS-06).
673
+ const failed = entries.filter((e) => e && !e.cleaned && e.failed).map((e) => e.project);
663
674
  if (!cleaned.length && !failed.length) return;
664
675
  const lines = [];
665
676
  if (cleaned.length) {
@@ -1089,7 +1100,16 @@ function hookFirePayload(matcher) {
1089
1100
  case 'Write|Edit':
1090
1101
  return { tool_name: 'Edit', tool_input: { file_path: 'src/example.rs', old_string: 'a', new_string: 'b' } };
1091
1102
  case '': // UserPromptSubmit
1092
- return { prompt: 'where is the parse function defined' };
1103
+ // A SYMPTOM-flavoured prompt, for the same reason the Bash probe uses a
1104
+ // quoted identifier: it must reach a path that actually emits. The old
1105
+ // payload ('where is the parse function defined') produced
1106
+ // `determineQueryType(...) === null` — no query, no output — and the two
1107
+ // result-injecting paths that would emit need a real indexed binary, which
1108
+ // this throwaway fixture does not have. `symptom-hint` is prose-only, so it
1109
+ // engages on the fixture alone. Field name is `prompt`: this constructor
1110
+ // has always had it right, while the hook itself read `message`
1111
+ // (audit 2026-08-29 JS-01).
1112
+ return { prompt: 'why does the parser crash on empty input' };
1093
1113
  default:
1094
1114
  return { tool_name: 'Unknown', tool_input: {} };
1095
1115
  }
@@ -1816,6 +1836,7 @@ module.exports = {
1816
1836
  install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
1817
1837
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1818
1838
  cleanupDisabledStatusline, unadoptRegisteredProjects,
1839
+ reportUnadoptSweep, // exported so its three-way bucketing is testable (audit 2026-08-29 JS-06)
1819
1840
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1820
1841
  backupCorruptFile, // auto-update.js repoints installed_plugins.json and owes the same preserve-then-proceed route
1821
1842
  migrateOldPluginIds, // exported so its failure arms are testable (audit 2026-08-22 P2-10)
@@ -45,20 +45,46 @@ if (!oldStr || oldStr.length < 10) process.exit(0);
45
45
 
46
46
  // --- Extract function/method signature from the edited text ---
47
47
  // Match function definitions across languages: Rust, JS/TS, Python, Go, Java/C#/Kotlin, Ruby, PHP
48
+ //
49
+ // Every unbounded run that is FOLLOWED BY A REQUIRED LITERAL carries an explicit
50
+ // `{1,128}` cap — longer than any real identifier, short enough that the engine
51
+ // gives up after 128 steps per start position. Without it those three patterns
52
+ // are quadratic: on a long
53
+ // unbroken \w run with no `name(...)` construct in it, the greedy run swallows to
54
+ // the end at EVERY start position and then backtracks a character at a time.
55
+ // Measured at HEAD on this box, pattern 4 alone: 10 KB 28 ms, 100 KB 2.8 s,
56
+ // 200 KB 11.0 s, 400 KB 43.4 s — doubling the input quadrupled the time.
57
+ //
58
+ // This needs no malice to hit. `old_string` is whatever the model is editing, so
59
+ // one benign blob without brackets — a base64 asset, a hex dump, a minified
60
+ // bundle, a long snake_case table — stalls a BLOCKING PreToolUse hook for
61
+ // seconds. Real code is unaffected either way (225 KB of this repo's own
62
+ // source: 0.3 ms), because a bracket ends the run almost immediately.
63
+ //
64
+ // The runs that are NOT capped are the ones nothing is required after
65
+ // (`fn\s+(\w+)`, `def\s+(\w+)`, …): those anchor on a keyword first and their
66
+ // trailing capture cannot backtrack, so a cap there would only truncate a long
67
+ // symbol name.
48
68
  const fnPatterns = [
49
69
  /(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/, // Rust
50
70
  /(?:export\s+)?(?:async\s+)?function\s+(\w+)/, // JS/TS
51
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|_)\s*=>/, // JS arrow
52
- /(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/, // JS method / Go func
71
+ /(?:const|let|var)\s+(\w{1,128})\s*=\s*(?:async\s+)?(?:\([^)]*\)|_)\s*=>/, // JS arrow
72
+ /(?:async\s+)?(\w{1,128})\s*\([^)]*\)\s*\{/, // JS method / Go func
53
73
  /def\s+(\w+)/, // Python/Ruby
54
74
  /func\s+(\w+)/, // Go/Swift
55
- /(?:public|private|protected|static|override|virtual|abstract|internal)\s+\S+\s+(\w+)\s*\(/, // Java/C#/Kotlin
75
+ /(?:public|private|protected|static|override|virtual|abstract|internal)\s+\S{1,128}\s+(\w{1,128})\s*\(/, // Java/C#/Kotlin
56
76
  /(?:public\s+)?function\s+(\w+)/, // PHP
57
77
  ];
58
78
 
79
+ // Second bound, on the INPUT rather than the patterns: a signature sits at the
80
+ // head of the edited hunk, so matching past the first 8 KB buys nothing and
81
+ // costs linearly. Belt to the caps' braces — it also bounds whatever pattern a
82
+ // future author adds to the array without reading the note above.
83
+ const scanned = oldStr.length > 8192 ? oldStr.slice(0, 8192) : oldStr;
84
+
59
85
  let symbol = null;
60
86
  for (const pat of fnPatterns) {
61
- const m = oldStr.match(pat);
87
+ const m = scanned.match(pat);
62
88
  if (m) {
63
89
  // Find the first captured group
64
90
  symbol = m[1] || m[2];
@@ -291,6 +291,20 @@ const { resolveProjectRoot } = require('./project-root');
291
291
  // (the exact shape that would re-create the answered:false glob failure).
292
292
  function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
293
293
  if (!cmd || typeof cmd !== 'string' || !relPrefix || !rootDir) return cmd;
294
+ // SEC-05 (audit 2026-08-29): one `exists()` syscall per surviving token, and
295
+ // this runs BEFORE every length gate in the file — `shouldHint`'s 1000-char
296
+ // sanity check (:159) and the 2000-char ones on the sed/tail extractors are all
297
+ // downstream of it, so the guard sat below the thing it was guarding. Measured
298
+ // with a counting stub: 100k tokens is 100,001 probes, 2.2 s of real
299
+ // `fs.existsSync` on this box, paid inside a BLOCKING PreToolUse hook.
300
+ //
301
+ // The bound is the loosest one the file already uses, so nothing that any
302
+ // downstream gate would still have processed changes behavior: a command this
303
+ // long is past the sed/tail extractors' limit and twice past `shouldHint`'s.
304
+ // Placed here rather than at the two call sites because both of them
305
+ // (`pre-grep-guide` runMain, `post-grep-inject` runMain) need it, and a guard
306
+ // that lives in the callers is one refactor away from being dropped.
307
+ if (cmd.length > 2000) return cmd;
294
308
  const prefix = relPrefix.split(path.sep).join('/');
295
309
  // Shell sits outside any known source dir (docs/, target/, …) — don't guess.
296
310
  if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
@@ -437,7 +437,24 @@ function runMain() {
437
437
  // sibling hooks already did this; this one was the holdout, so it read
438
438
  // nothing under exactly the conditions verifyHooksFire spawns it with.
439
439
  const input = JSON.parse(fs.readFileSync(0, 'utf8'));
440
- message = (input && input.message) || '';
440
+ // `prompt` FIRST: that is the field Claude Code puts the user's text in on
441
+ // UserPromptSubmit, and this hook read only `message` — so in production it
442
+ // parsed a payload, found nothing, and exited 0 in silence. The whole
443
+ // intent-driven injection surface (impact / callgraph / overview / search +
444
+ // the symptom hint) was dead from the day it shipped, and its adoption
445
+ // metrics read as a structural zero.
446
+ //
447
+ // Two things hid it, both worth naming because either alone would have been
448
+ // enough: the entire test suite fed `{message:…}` — a self-consistent copy
449
+ // of the defect — and `verifyHooksFire` only asserts exit 0, which is
450
+ // exactly what a hook that reads nothing does. The production tell was
451
+ // outside both: zero `.code-graph-ctx-*` cooldown flags in a heavily
452
+ // dogfooded tmp dir that held 93 flags from the sibling hooks
453
+ // (audit 2026-08-29 JS-01).
454
+ //
455
+ // `message` is kept as a fallback rather than replaced — it costs one `||`
456
+ // and covers any host that spells it the other way.
457
+ message = (input && (input.prompt || input.message)) || '';
441
458
  } catch {
442
459
  return;
443
460
  }
@@ -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.126.2 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.128.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.126.2",
3
+ "version": "0.128.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.126.2",
39
- "@sdsrs/code-graph-linux-arm64": "0.126.2",
40
- "@sdsrs/code-graph-darwin-x64": "0.126.2",
41
- "@sdsrs/code-graph-darwin-arm64": "0.126.2",
42
- "@sdsrs/code-graph-win32-x64": "0.126.2"
38
+ "@sdsrs/code-graph-linux-x64": "0.128.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.128.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.128.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.128.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.128.0"
43
43
  }
44
44
  }