@sdsrs/code-graph 0.130.0 → 0.131.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.130.0",
7
+ "version": "0.131.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -27,6 +27,105 @@ const NO_METRICS_FILE = '.no-metrics';
27
27
  const ROTATE_MAX_BYTES = 1048576; // 1 MB
28
28
  const ROTATE_KEEP_BYTES = 524288; // 512 KB
29
29
 
30
+ // Unix-only, atomic: refuse to traverse a final-component symlink on the open
31
+ // itself. Undefined on Windows, where the `lstat` / `fstat` checks below are the
32
+ // only layer — same split as the Rust `utils::owned`. Each layer is pinned by
33
+ // its own test (`refuses … with O_NOFOLLOW unavailable` covers the Windows
34
+ // shape); a suite that only pins the pair lets either half be deleted green.
35
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
36
+
37
+ // Keyed by path, not a bare boolean: every hook is a one-shot process today, but
38
+ // a module-global flag would silence the diagnostic for the SECOND project if
39
+ // this is ever required from something long-lived.
40
+ const warnedPaths = new Set();
41
+
42
+ function warnNotOwned(p, why) {
43
+ if (warnedPaths.has(p)) return;
44
+ warnedPaths.add(p);
45
+ try {
46
+ process.stderr.write(`[code-graph] skipping adoption metrics: ${p} ${why}\n`);
47
+ } catch {
48
+ /* stderr closed → nothing to do */
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Reject anything at `p` that is not the ordinary file/directory this module
54
+ * expects to own. JS twin of Rust `utils::owned::refuse_non_regular` /
55
+ * `reject_symlinked_dir`: `.code-graph/` is ordinary repo content, so one
56
+ * `git clone` can carry a symlink where this module expects its own file, and
57
+ * `writeFileSync` / `appendFileSync` both follow it out of the project (audit
58
+ * 2026-09-02 P1-1: a 1.2 MB file outside the tree truncated by the rotator).
59
+ *
60
+ * Symlink is tested FIRST so each rejection gets its own diagnostic, matching
61
+ * `reject_symlinked_dir`. The first cut tested `isDirectory()` first, which is
62
+ * false for a symlink-to-directory — so the symlink arm never ran, the distinct
63
+ * message was dead, and deleting the whole call changed nothing (pre-tag review,
64
+ * P3-1).
65
+ *
66
+ * Absent is fine — the open will create it.
67
+ * @param {string} p absolute path
68
+ * @param {'file'|'dir'} kind
69
+ * @param {fs.Stats} [known] an lstat the caller already took, to avoid a second
70
+ * @returns {boolean} true if `p` is safe to write
71
+ */
72
+ function isOwnedPath(p, kind, known) {
73
+ let st = known;
74
+ if (!st) {
75
+ try {
76
+ st = fs.lstatSync(p);
77
+ } catch {
78
+ return true; // absent (or unreadable) → leave it to the real syscall
79
+ }
80
+ }
81
+ if (st.isSymbolicLink()) {
82
+ warnNotOwned(p, 'is a symlink (following it would write outside the project)');
83
+ return false;
84
+ }
85
+ if (kind === 'dir' ? st.isDirectory() : st.isFile()) return true;
86
+ warnNotOwned(p, 'is not a regular file');
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Open `file` for writing only if the thing actually opened is a file this
92
+ * module owns. `fstat` on the returned descriptor — not `lstat` on the path —
93
+ * because that is the only check that describes the object the subsequent write
94
+ * lands on, closing the lstat→open window on the platforms where `O_NOFOLLOW`
95
+ * is 0.
96
+ *
97
+ * `nlink > 1` refuses a HARDLINK. `lstat` reports a hardlinked victim as a
98
+ * plain regular file and `O_NOFOLLOW` says nothing about one, so the symlink
99
+ * guard alone left the identical damage reachable — measured by the pre-tag
100
+ * review at 1,200,020 → 67 bytes, same as the symlink case. `git` cannot
101
+ * deliver a hardlink (its index stores no such mode) but `tar x` can.
102
+ *
103
+ * Callers must NOT pass `O_TRUNC`: truncation would happen on the open, before
104
+ * any of this could look. Truncate through the returned descriptor instead.
105
+ * @returns {number|null} an open fd the caller must close, or null if refused
106
+ */
107
+ function openOwned(file, flags) {
108
+ const fd = fs.openSync(file, flags | O_NOFOLLOW);
109
+ let st;
110
+ try {
111
+ st = fs.fstatSync(fd);
112
+ } catch (e) {
113
+ fs.closeSync(fd);
114
+ throw e;
115
+ }
116
+ if (!st.isFile()) {
117
+ warnNotOwned(file, 'is not a regular file');
118
+ fs.closeSync(fd);
119
+ return null;
120
+ }
121
+ if (st.nlink > 1) {
122
+ warnNotOwned(file, 'has more than one hard link (the write would reach another path)');
123
+ fs.closeSync(fd);
124
+ return null;
125
+ }
126
+ return fd;
127
+ }
128
+
30
129
  /**
31
130
  * Best-effort size-based rotation: if `file` exceeds ROTATE_MAX_BYTES, rewrite
32
131
  * it keeping ~the last ROTATE_KEEP_BYTES, trimmed *forward* to the next line
@@ -42,7 +141,16 @@ function rotateIfNeeded(file) {
42
141
  const start = Math.max(0, buf.length - ROTATE_KEEP_BYTES);
43
142
  const nl = buf.indexOf(0x0a, start); // first newline at/after start
44
143
  const trimStart = nl >= 0 ? nl + 1 : start;
45
- fs.writeFileSync(file, buf.subarray(trimStart));
144
+ // No O_TRUNC: it would empty the target on the open, before `openOwned`
145
+ // could refuse it. Truncate through the descriptor once it is vouched for.
146
+ const fd = openOwned(file, fs.constants.O_WRONLY);
147
+ if (fd === null) return;
148
+ try {
149
+ fs.ftruncateSync(fd, 0);
150
+ fs.writeSync(fd, buf.subarray(trimStart));
151
+ } finally {
152
+ fs.closeSync(fd);
153
+ }
46
154
  } catch {
47
155
  /* missing file or IO error → skip; the append below still runs */
48
156
  }
@@ -58,17 +166,36 @@ function recordRecommendation(cwd, event = {}) {
58
166
  try {
59
167
  const dir = path.join(cwd, '.code-graph');
60
168
  // Append-only: do NOT create .code-graph. Its absence means "not an indexed
61
- // project" — recording there would pollute non-project cwds.
62
- if (!fs.existsSync(dir)) return false;
169
+ // project" — recording there would pollute non-project cwds. `lstat`, not
170
+ // `existsSync`: a symlinked `.code-graph` holding perfectly ordinary files
171
+ // defeats the per-file guard below, because the write then lands on a real
172
+ // regular file that simply is not where this hook thinks it is.
173
+ let dirStat;
174
+ try {
175
+ dirStat = fs.lstatSync(dir);
176
+ } catch {
177
+ return false; // absent → not an indexed project
178
+ }
179
+ if (!isOwnedPath(dir, 'dir', dirStat)) return false;
63
180
  // Opt-in metrics silence for dev/dogfood checkouts: when the project marks
64
181
  // itself with `.code-graph/.no-metrics`, the tool's own hook/CLI runs (sims,
65
182
  // functionality testing) must not self-pollute its adoption metrics. Mirrors
66
183
  // the Rust cli::record_cli_use guard. Reversible: delete the file to re-enable.
67
184
  if (fs.existsSync(path.join(dir, NO_METRICS_FILE))) return false;
68
185
  const file = path.join(dir, REC_FILE);
186
+ if (!isOwnedPath(file, 'file')) return false;
69
187
  rotateIfNeeded(file); // rotate-before-append so the file never exceeds ~max + one line
70
188
  const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
71
- fs.appendFileSync(file, line);
189
+ const fd = openOwned(
190
+ file,
191
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
192
+ );
193
+ if (fd === null) return false;
194
+ try {
195
+ fs.writeSync(fd, line);
196
+ } finally {
197
+ fs.closeSync(fd);
198
+ }
72
199
  return true;
73
200
  } catch {
74
201
  return false;
@@ -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.130.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.131.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.130.0",
3
+ "version": "0.131.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.130.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.130.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.130.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.130.0",
42
- "@sdsrs/code-graph-win32-x64": "0.130.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.131.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.131.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.131.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.131.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.131.0"
43
43
  }
44
44
  }