graph-indexer 3.0.0 → 3.0.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to graph-indexer are documented here. Dates are in YYYY-MM-D
4
4
 
5
5
  ---
6
6
 
7
+ ## [3.0.1] — 2026-10-23
8
+
9
+ A security release. Upgrading is recommended for everyone; nothing else changes.
10
+
11
+ ### Security
12
+
13
+ - **Node.js 22.23.4+, 24.22.1+ or 26.12.1+** is now required (`engines`). These are the October
14
+ 2026 Node.js security releases, which fix CVE-2026-111497 (out-of-bounds write in the Brotli
15
+ decoder, remote code execution) and nine other vulnerabilities in TLS, DNS, HTTP keep-alive,
16
+ `crypto.randomUUID()` and the Permission Model. graph-indexer does not decompress Brotli itself,
17
+ but it runs inside the Node.js process the user has installed.
18
+ - **npx no longer runs a copy of graph-indexer from the repository.** Started from a repository
19
+ that ships `node_modules/graph-indexer` (3.x) with its `.bin` entry, `npx -y graph-indexer@3`
20
+ ran that code instead of the published package: the Claude Code plugin's hooks and MCP server,
21
+ the OpenCode / Kilo Code plugin and the configurations written by `init` did, on opening the
22
+ repository. Every npx command now passes `--prefix` so only the published package runs. Re-run
23
+ `npx -y --prefix / graph-indexer@3 init` to rewrite existing configurations.
24
+ - **`check_changes` refuses a `base` that git would read as an option.** `base: "--output=<file>"`
25
+ made `git diff` create or truncate any file the user can write. A revision must not start with
26
+ `-` or contain whitespace.
27
+ - **`read_code` reads only files inside the repository.** `../../etc/hosts` and a tracked symbolic
28
+ link to a file elsewhere were read and served. Paths are now resolved (`..` and links) and must
29
+ stay under the root; `.git/` and `.graph-indexer/` are not served. `search_text` and
30
+ `check_changes` skip links out of the repository too.
31
+ - **`search_text` stops a regular expression that backtracks catastrophically** (`(a+)+$`) after
32
+ 5 s and says so, instead of blocking the server and every other request.
33
+ - **The suggested test commands quote every file name** (a test file named `t$(cmd).test.ts`
34
+ reaches the runner as a name, not as a command substitution), since agents run those commands
35
+ as they are; test names that are not plain identifiers get no command.
36
+ - **git runs nothing that the indexed repository's own config names**: `core.fsmonitor`, hooks
37
+ (`core.hooksPath`), signature verification (`log.showSignature`), clean/smudge/process filters,
38
+ `diff.external` and textconv drivers are switched off on every git call, for repositories copied
39
+ together with their `.git` directory. Required filters such as Git LFS's no longer fail the diff.
40
+ - **A `.graph-indexer` committed as a symbolic link is refused** instead of having the index (which
41
+ contains excerpts of the source) written wherever it points. The directory is created with mode
42
+ 0700. The hook clients only talk to a socket, and a socket directory, owned by the user.
43
+ - **Manifests are read only as small regular files inside the repository**: a `tsconfig.json`,
44
+ `package.json` or `go.mod` linked to `/dev/zero` no longer hangs the indexer.
45
+ - **A file whose syntax makes queries super-linear** (tens of thousands of chained calls) is given
46
+ up after 15 s instead of stalling the index.
47
+ - **A field assigned an element of itself** (`self.impl = self.impl[0]`) no longer overflows the
48
+ indexer's stack.
49
+ - **Names are escaped as regular expressions** wherever they are matched in text, not only `$`, and
50
+ a `file:name` target no longer backtracks polynomially.
51
+ - **`init` does not write through links the repository committed** (`CLAUDE.md` → `~/.zshrc`,
52
+ `.claude` → `~/.claude`); it stops and names the file.
53
+ - **Per-session hook state** lives in a per-user directory with mode 0700 and is written
54
+ atomically, so another user of a shared temp directory cannot read it or redirect the write.
55
+ - On Windows, the hook clients quote the arguments they hand to `cmd.exe`.
56
+
57
+ ---
58
+
7
59
  ## [3.0.0] — 2026-09-23
8
60
 
9
61
  A rewrite around one goal: give coding agents answers that are exact, current and small.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/v/graph-indexer?color=007acc&style=for-the-badge" alt="npm version"></a>
11
11
  <a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/dt/graph-indexer?color=4caf50&style=for-the-badge" alt="NPM Downloads"></a>
12
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-%3E%3D22.5-brightgreen?style=for-the-badge&logo=nodedotjs" alt="Node.js 22.5+"></a>
12
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-22.23.4%2B%20%7C%2024.22.1%2B%20%7C%2026.12.1%2B-brightgreen?style=for-the-badge&logo=nodedotjs" alt="Node.js 22.23.4+, 24.22.1+ or 26.12.1+"></a>
13
13
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"></a>
14
14
  </div>
15
15
 
@@ -40,14 +40,18 @@ instead of reading whole files and grepping.
40
40
 
41
41
  ## Quick start
42
42
 
43
- Requires **Node.js 22.5+** (for the built-in `node:sqlite`).
43
+ Requires **Node.js 22.23.4+, 24.22.1+ or 26.12.1+** (the built-in `node:sqlite`, and the October 2026
44
+ Node.js security releases, which fix CVE-2026-111497).
44
45
 
45
46
  ```bash
46
47
  cd your-repo
47
- npx graph-indexer init # wires the MCP server into the agents it detects
48
- npx graph-indexer index # optional: build the index now (the server also does it on start)
48
+ npx -y --prefix / graph-indexer@3 init # wires the MCP server into the agents it detects
49
+ npx -y --prefix / graph-indexer@3 index # optional: build the index now (the server also does it on start)
49
50
  ```
50
51
 
52
+ `--prefix /` makes npx run the published package even in a repository that ships its own
53
+ `node_modules/graph-indexer`.
54
+
51
55
  `init` writes project-level MCP configuration for the agents it finds (`.mcp.json` for Claude
52
56
  Code, `.cursor/mcp.json`, `.vscode/mcp.json`, `.gemini/settings.json`, `opencode.json` for
53
57
  OpenCode and Kilo Code, `.junie/mcp/mcp.json`, `.zed/settings.json`; it prints the Codex TOML
@@ -70,7 +74,7 @@ Any MCP client can run the server over stdio:
70
74
  ```json
71
75
  {
72
76
  "mcpServers": {
73
- "graph-indexer": { "command": "npx", "args": ["-y", "graph-indexer@3", "serve", "--repo", "/path/to/repo"] }
77
+ "graph-indexer": { "command": "npx", "args": ["-y", "--prefix", "/", "graph-indexer@3", "serve", "--repo", "/path/to/repo"] }
74
78
  }
75
79
  }
76
80
  ```
@@ -81,7 +85,7 @@ VS Code uses `"servers"` instead of `"mcpServers"` and `"type": "stdio"`. Codex
81
85
  ```toml
82
86
  [mcp_servers.graph-indexer]
83
87
  command = "npx"
84
- args = ["-y", "graph-indexer@3", "serve", "--repo", "/path/to/repo"]
88
+ args = ["-y", "--prefix", "/", "graph-indexer@3", "serve", "--repo", "/path/to/repo"]
85
89
  ```
86
90
 
87
91
  Without `--repo` the server indexes the git repository containing its working directory
@@ -276,7 +280,7 @@ Version 3 is a rewrite; see the [changelog](CHANGELOG.md). In short: the tools c
276
280
  `check_changes`, `outline`), the
277
281
  embedding, Ollama, reranker and daemon options are gone (search is lexical plus graph and needs
278
282
  no model), grammars are bundled instead of installed on first use, and Node.js 22.5+ is required.
279
- Re-run `npx graph-indexer init` to update agent configuration; old `.graph-indexer/` contents are
283
+ Re-run `npx -y --prefix / graph-indexer@3 init` to update agent configuration; old `.graph-indexer/` contents are
280
284
  replaced automatically.
281
285
 
282
286
  ## License
package/SECURITY.md CHANGED
@@ -33,13 +33,24 @@ graph-indexer is a local developer tool. Its threat model follows from a few del
33
33
  (`node:sqlite`, `node:fs`, `node:child_process`, …) and the vendored WebAssembly runtime.
34
34
  - **No code execution.** Source files are parsed into syntax trees; the indexed code is never
35
35
  imported, evaluated or run. The only subprocesses are read-only git commands
36
- (`git rev-parse`, `git ls-files`, `git diff`, `git log`) run in the indexed repository with
37
- fixed arguments; no tool input is passed to them.
36
+ (`git rev-parse`, `git ls-files`, `git diff`, `git log`, `git show`) run in the indexed repository
37
+ with fixed arguments. The only tool input they take is the `base` revision of `check_changes`,
38
+ which is refused when git could read it as an option. Every git call switches off what the
39
+ repository's own git config could run: `core.fsmonitor`, hooks, signature verification,
40
+ clean/smudge/process filters, `diff.external` and textconv drivers.
41
+ - **No code from the repository through npx.** Every `npx` command graph-indexer runs or writes into
42
+ an agent's configuration passes `--prefix`, so a `node_modules/graph-indexer` that the repository
43
+ ships is never run in place of the published package.
38
44
  - **Stays inside the repository.** Only files listed by git (or found by a `.gitignore`-aware
39
45
  walk) under the repository root are indexed. Symlinks are followed only when their target
40
46
  resolves inside the repository, so a link to a file elsewhere on the machine is never read,
41
47
  indexed or served. Paths given to the tools (`read_code "file.ts:42"`, `outline`,
42
- `change_impact`) are looked up in the index, never opened directly from disk.
48
+ `change_impact`) are resolved (`..` and symbolic links) and must stay inside the repository;
49
+ `.git/` and `.graph-indexer/` are never served. Manifests (`package.json`, `tsconfig.json`,
50
+ `go.mod`) are read only as small regular files inside the repository.
51
+ - **Bounded work.** A regular expression given to `search_text` runs under a 5 s budget, and a file
52
+ whose syntax makes the structural queries super-linear is given up after 15 s, so neither can
53
+ block the server.
43
54
  - **Read-only tools.** All MCP tools are read-only (`readOnlyHint: true`) and never modify
44
55
  the repository.
45
56
  - **stdio transport.** The MCP server talks to the local client over stdin/stdout. It does not
@@ -51,10 +62,16 @@ graph-indexer is a local developer tool. Its threat model follows from a few del
51
62
  both sides check before using it. It answers the same read-only queries as the CLI.
52
63
  `GRAPH_INDEXER_RESIDENT=0` disables it.
53
64
  - **Local artifacts.** The index is a SQLite database in `<repo>/.graph-indexer/` (git-ignored by
54
- `init`). It contains excerpts of your source code — treat it with the same sensitivity as the
65
+ `init`, mode 0700; a `.graph-indexer` that is a symbolic link is refused). It contains excerpts of your source code — treat it with the same sensitivity as the
55
66
  repository itself and do not commit or share it. Deleting the directory is always safe; it is
56
67
  rebuilt on the next run.
57
68
 
69
+ ## Runtime
70
+
71
+ graph-indexer runs in the Node.js the user installs. It requires a release that includes the
72
+ October 2026 Node.js security fixes (22.23.4+, 24.22.1+ or 26.12.1+; CVE-2026-111497 and others).
73
+ Keep Node.js updated to the latest patch release of a supported line.
74
+
58
75
  ## Operational guidance
59
76
 
60
77
  - Run the server only on repositories you trust and intend to index.
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Live code graph for Claude Code: exact references, structural grep, change impact and a post-edit check, from a local index with no model or network.",
5
5
  "author": { "name": "MaquinaTech", "url": "https://github.com/MaquinaTech" },
6
6
  "homepage": "https://github.com/MaquinaTech/graph-indexer",
7
- "license": "MIT"
7
+ "license": "MIT",
8
+ "icon": "https://raw.githubusercontent.com/MaquinaTech/graph-indexer/main/assets/logo.jpg"
8
9
  }
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "graph-indexer": {
4
4
  "command": "npx",
5
- "args": ["-y", "graph-indexer@3", "serve"]
5
+ "args": ["-y", "--prefix", "/", "graph-indexer@3.0.1", "serve"]
6
6
  }
7
7
  }
8
8
  }
@@ -43,11 +43,12 @@ export function socketPath(root) {
43
43
 
44
44
  function trusted(file, root) {
45
45
  if (WIN) return true;
46
- const dir = path.dirname(file);
47
- if (dir === path.join(path.resolve(root), '.graph-indexer')) return fs.existsSync(file);
46
+ const mine = (st) => !process.getuid || st.uid === process.getuid();
48
47
  try {
49
- const st = fs.lstatSync(dir);
50
- return fs.existsSync(file) && st.isDirectory() && (!process.getuid || st.uid === process.getuid()) && (st.mode & 0o077) === 0;
48
+ const sock = fs.lstatSync(file), dir = fs.lstatSync(path.dirname(file));
49
+ const inRepo = path.dirname(file) === path.join(path.resolve(root), '.graph-indexer');
50
+ // the socket and its directory are ours, not links or listeners planted by the repository or another user
51
+ return sock.isSocket() && mine(sock) && dir.isDirectory() && mine(dir) && (inRepo || (dir.mode & 0o077) === 0);
51
52
  } catch { return false; }
52
53
  }
53
54
 
@@ -85,8 +86,11 @@ async function main() {
85
86
  const root = findRepoRoot(input.cwd || process.cwd());
86
87
  const reply = await ask(root, { op: 'hook', event, input }, 2500);
87
88
  if (reply) { if (reply.out) process.stdout.write(reply.out + '\n'); return; }
88
- // no resident yet: the full hook through npx (it starts one for the next calls)
89
- const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', 'graph-indexer@3', 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
89
+ // no resident yet: the full hook through npx (it starts one for the next calls); --prefix keeps
90
+ // npx from running a graph-indexer that the repository ships in its own node_modules
91
+ // (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
92
+ const home = WIN ? `"${os.homedir()}"` : os.homedir();
93
+ const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home, 'graph-indexer@3', 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
90
94
  child.on('error', () => { /* fail open */ });
91
95
  child.stdin.on('error', () => { /* fail open */ });
92
96
  child.stdin.end(raw);
@@ -19,8 +19,9 @@ import { spawn } from 'node:child_process';
19
19
 
20
20
  const PROTOCOL = 1;
21
21
  const WIN = process.platform === 'win32';
22
- // how to start a resident when none answers (rewritten by `init --local` and installed binaries)
23
- const DAEMON = ['npx', '-y', 'graph-indexer@3', 'daemon'];
22
+ // how to start a resident when none answers (rewritten by `init --local` and installed binaries);
23
+ // --prefix keeps npx from running a graph-indexer that the repository ships in its own node_modules
24
+ const DAEMON = ['npx', '-y', '--prefix', os.homedir(), 'graph-indexer@3', 'daemon'];
24
25
  // OpenCode tool ids → the tool names graph-indexer's hook understands
25
26
  const TOOLS = { read: 'Read', grep: 'Grep', bash: 'Bash', edit: 'Edit', write: 'Write' };
26
27
 
@@ -46,12 +47,12 @@ function socketPath(root) {
46
47
 
47
48
  function trusted(file, root) {
48
49
  if (WIN) return true;
49
- if (!fs.existsSync(file)) return false;
50
- const dir = path.dirname(file);
51
- if (dir === path.join(path.resolve(root), '.graph-indexer')) return true;
50
+ const mine = (st) => !process.getuid || st.uid === process.getuid();
52
51
  try {
53
- const st = fs.lstatSync(dir);
54
- return st.isDirectory() && (!process.getuid || st.uid === process.getuid()) && (st.mode & 0o077) === 0;
52
+ const sock = fs.lstatSync(file), dir = fs.lstatSync(path.dirname(file));
53
+ const inRepo = path.dirname(file) === path.join(path.resolve(root), '.graph-indexer');
54
+ // the socket and its directory are ours, not links or listeners planted by the repository or another user
55
+ return sock.isSocket() && mine(sock) && dir.isDirectory() && mine(dir) && (inRepo || (dir.mode & 0o077) === 0);
55
56
  } catch { return false; }
56
57
  }
57
58
 
@@ -107,7 +108,11 @@ export const GraphIndexer = async ({ directory, worktree } = {}) => {
107
108
  if (!reply) {
108
109
  if (!started) {
109
110
  started = true;
110
- const child = spawn(DAEMON[0], [...DAEMON.slice(1), '--repo', root], { detached: true, stdio: 'ignore', windowsHide: true, shell: WIN });
111
+ // cmd.exe (npx.cmd needs a shell on Windows) would run `&`, `|`… in a path: quote
112
+ // every argument, and give up on one that a quote cannot hold
113
+ const args = [...DAEMON.slice(1), '--repo', root];
114
+ if (WIN && args.some(a => /["%\r\n]/.test(a))) return;
115
+ const child = spawn(DAEMON[0], WIN ? args.map(a => `"${a}"`) : args, { detached: true, stdio: 'ignore', windowsHide: true, shell: WIN });
111
116
  child.on('error', () => { /* optional */ });
112
117
  child.unref();
113
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Live code graph and search for AI coding agents over MCP: tree-sitter (WASM) symbols, scope/import/type-aware references, call graphs, change impact and hybrid search — local, zero-dependency, no native builds.",
5
5
  "type": "module",
6
6
  "main": "src/query/intel.mjs",
@@ -34,7 +34,7 @@
34
34
  "index": "node bin/graph-indexer.mjs index"
35
35
  },
36
36
  "engines": {
37
- "node": ">=22.5.0"
37
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
38
38
  },
39
39
  "repository": {
40
40
  "type": "git",
package/src/cli/hook.mjs CHANGED
@@ -31,7 +31,7 @@ import os from 'node:os';
31
31
  import path from 'node:path';
32
32
  import { spawn } from 'node:child_process';
33
33
  import { fileURLToPath } from 'node:url';
34
- import { findRepoRoot, dataDir, DATA_DIR_NAME } from '../util/paths.mjs';
34
+ import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.mjs';
35
35
  import { specForPath } from '../parse/languages.mjs';
36
36
 
37
37
  const MAX_CONTEXT = 1500;
@@ -78,16 +78,26 @@ function responseText(input) {
78
78
  /** Navigation calls since the last edit and definitions already shown, per session (crawl detector). */
79
79
  function sessionState(input) {
80
80
  const id = String(input.session_id ?? input.sessionId ?? input.conversation_id ?? 'default').replace(/[^\w.-]/g, '_').slice(0, 80);
81
- const file = path.join(os.tmpdir(), 'graph-indexer-hooks', `${id}.json`);
81
+ // a per-user directory closed to others: in a shared temp dir another user could otherwise read
82
+ // the state or plant links that the next save writes through
83
+ const dir = path.join(os.tmpdir(), `graph-indexer-hooks-${process.getuid?.() ?? 'user'}`);
84
+ const file = path.join(dir, `${id}.json`);
85
+ const privateDir = () => {
86
+ if (process.platform === 'win32') return true;
87
+ try { const d = fs.lstatSync(dir); return d.isDirectory() && d.uid === process.getuid() && (d.mode & 0o077) === 0; } catch { return false; }
88
+ };
82
89
  let st = { nav: 0, shown: [] };
83
- try { st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
90
+ try { if (privateDir() && fs.lstatSync(file).isFile()) st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
84
91
  return {
85
92
  st,
86
93
  save() {
87
94
  try {
88
- fs.mkdirSync(path.dirname(file), { recursive: true });
95
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ if (!privateDir()) return;
89
97
  st.shown = st.shown.slice(-400);
90
- fs.writeFileSync(file, JSON.stringify(st));
98
+ const tmp = `${file}.${process.pid}.tmp`;
99
+ fs.writeFileSync(tmp, JSON.stringify(st), { flag: 'wx', mode: 0o600 });
100
+ fs.renameSync(tmp, file);
91
101
  } catch { /* optional */ }
92
102
  },
93
103
  };
@@ -269,7 +279,7 @@ async function afterRead(root, input, state, crawling, acquire) {
269
279
  const RULES = `How to look code up in this repository (graph-indexer):
270
280
  - Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
271
281
  - The function or the 50–100 lines you need, not whole files; to find a definition, search for its definition line (\`def name\`, \`class Name\`) across the package in one search.
272
- - Exact uses of a function or class, even when other code shares its name: find_references (\`npx graph-indexer refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
282
+ - Exact uses of a function or class, even when other code shares its name: find_references (\`npx -y --prefix / graph-indexer@3 refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
273
283
 
274
284
  async function sessionLine(root, { rules = false, acquire }) {
275
285
  if (!hasIndex(root)) return null; // being built in the background (spawnResident); say nothing yet
@@ -280,7 +290,7 @@ async function sessionLine(root, { rules = false, acquire }) {
280
290
  const st = intel.stats();
281
291
  const line = `graph-indexer has a live index of this repository (${st.files} files, ${st.symbols} symbols). Its MCP tools answer with exact locations: find_references (exact uses, even when other code shares the name), change_impact (what a change affects and which tests to run), check_changes (what an edit broke, and the closest tests), call_graph, read_code (symbols or ranges by name, with where each name they use is defined), search_text, search_code and outline.`;
282
292
  // the managed block of `init` already carries the rules; otherwise add them
283
- const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => { try { return fs.readFileSync(path.join(root, f), 'utf8').includes('<!-- graph-indexer:start -->'); } catch { return false; } });
293
+ const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => (readRepoFile(root, f) ?? '').includes('<!-- graph-indexer:start -->'));
284
294
  return rules || !carried ? `${line}\n${RULES}` : line;
285
295
  } finally { ix.release(); }
286
296
  }
package/src/cli/init.mjs CHANGED
@@ -52,13 +52,17 @@ function readJson(p) {
52
52
  try { return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
53
53
  }
54
54
 
55
+ // --prefix /: npx would otherwise run a graph-indexer 3.x found in the repository's own node_modules
56
+ // (code the repository ships) instead of the published package
57
+ const NPX_ARGS = ['-y', '--prefix', '/', 'graph-indexer@3'];
58
+
55
59
  function serverEntry(agent, { local }) {
56
60
  const here = path.dirname(fileURLToPath(import.meta.url));
57
61
  const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
58
62
  const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
59
63
  const base = local
60
64
  ? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
61
- : { command: 'npx', args: ['-y', 'graph-indexer@3', 'serve', ...repoArgs] };
65
+ : { command: 'npx', args: [...NPX_ARGS, 'serve', ...repoArgs] };
62
66
  if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
63
67
  if (agent.zed) return { ...base, env: {} };
64
68
  return agent.vscode ? { type: 'stdio', ...base } : base;
@@ -70,7 +74,24 @@ function configFile(agent, repo) {
70
74
  return agent.file;
71
75
  }
72
76
 
73
- function upsertBlock(file, dryRun) {
77
+ /**
78
+ * Write `file` under `repo` without following a link the repository committed (`CLAUDE.md` →
79
+ * ~/.zshrc, `.claude` → ~/.claude): such a target is refused instead of written through.
80
+ */
81
+ function writeInRepo(repo, file, content) {
82
+ const rootReal = fs.realpathSync(repo);
83
+ const inside = (p) => { const r = fs.realpathSync(p); return r === rootReal || r.startsWith(rootReal + path.sep); };
84
+ let dir = path.dirname(file);
85
+ while (!fs.existsSync(dir)) dir = path.dirname(dir);
86
+ let link = false;
87
+ try { link = fs.lstatSync(file).isSymbolicLink(); } catch { /* a new file */ }
88
+ if (link || !inside(dir)) throw new Error(`${path.relative(repo, file)} leads outside the repository (a symbolic link); not written`);
89
+ fs.mkdirSync(path.dirname(file), { recursive: true });
90
+ if (!inside(path.dirname(file))) throw new Error(`${path.relative(repo, file)} leads outside the repository (a symbolic link); not written`);
91
+ fs.writeFileSync(file, content);
92
+ }
93
+
94
+ function upsertBlock(repo, file, dryRun) {
74
95
  const exists = fs.existsSync(file);
75
96
  const text = exists ? fs.readFileSync(file, 'utf8') : '';
76
97
  let next;
@@ -80,7 +101,7 @@ function upsertBlock(file, dryRun) {
80
101
  next = (text.trimEnd() ? text.trimEnd() + '\n\n' : '') + SNIPPET + '\n';
81
102
  }
82
103
  if (next === text) return 'unchanged';
83
- if (!dryRun) fs.writeFileSync(file, next);
104
+ if (!dryRun) writeInRepo(repo, file, next);
84
105
  return exists ? 'updated' : 'created';
85
106
  }
86
107
 
@@ -100,7 +121,7 @@ function cliCommand(local) {
100
121
  const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
101
122
  if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
102
123
  const installed = installedBin();
103
- return installed ? JSON.stringify(installed) : 'npx -y graph-indexer@3';
124
+ return installed ? JSON.stringify(installed) : ['npx', ...NPX_ARGS].join(' ');
104
125
  }
105
126
 
106
127
  /** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
@@ -108,7 +129,7 @@ function cliArgv(local) {
108
129
  const here = path.dirname(fileURLToPath(import.meta.url));
109
130
  if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
110
131
  const installed = installedBin();
111
- return installed ? [installed] : ['npx', '-y', 'graph-indexer@3'];
132
+ return installed ? [installed] : ['npx', ...NPX_ARGS];
112
133
  }
113
134
 
114
135
  /**
@@ -128,8 +149,7 @@ function writeOpenCodePlugin(repo, local, dryRun) {
128
149
  const file = path.join(repo, rel);
129
150
  const same = fs.existsSync(file) && fs.readFileSync(file, 'utf8') === src;
130
151
  if (!same && !dryRun) {
131
- fs.mkdirSync(path.dirname(file), { recursive: true });
132
- fs.writeFileSync(file, src);
152
+ writeInRepo(repo, file, src);
133
153
  }
134
154
  out.push(`${same ? 'already in' : 'written to'} ${rel}`);
135
155
  }
@@ -160,8 +180,7 @@ function upsertClaudeHooks(repo, local, dryRun) {
160
180
  }
161
181
  const changed = before !== JSON.stringify(cfg.hooks);
162
182
  if (changed && !dryRun) {
163
- fs.mkdirSync(path.dirname(file), { recursive: true });
164
- fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
183
+ writeInRepo(repo, file, JSON.stringify(cfg, null, 2) + '\n');
165
184
  }
166
185
  return `Claude Code hooks: ${changed ? 'written to' : 'already in'} .claude/settings.json (post-edit check, grep disambiguation, session line)`;
167
186
  }
@@ -178,8 +197,7 @@ function upsertClaudeHelper(repo, dryRun) {
178
197
  if (have === want) return `Claude Code: structural helper already in ${rel}`;
179
198
  if (have != null && !have.includes("graph-indexer's index")) return `Claude Code: ${rel} exists and is not graph-indexer's, so it was left as is`;
180
199
  if (!dryRun) {
181
- fs.mkdirSync(path.dirname(file), { recursive: true });
182
- fs.writeFileSync(file, want);
200
+ writeInRepo(repo, file, want);
183
201
  }
184
202
  return `Claude Code: structural helper ${have == null ? 'written to' : 'updated in'} ${rel} (a sub-agent on a small model that answers call-site, caller, subclass and impact questions from the index)`;
185
203
  }
@@ -221,8 +239,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
221
239
  cfg[a.key]['graph-indexer'] = entry;
222
240
  const changed = before !== JSON.stringify(entry);
223
241
  if (changed && !dryRun) {
224
- fs.mkdirSync(path.dirname(file), { recursive: true });
225
- fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
242
+ writeInRepo(repo, file, JSON.stringify(cfg, null, 2) + '\n');
226
243
  }
227
244
  report.push(`${a.label}: ${changed ? (before === 'null' ? 'added' : 'updated') : 'already configured'} ${rel}`);
228
245
  } else if (key === 'codex') {
@@ -239,17 +256,17 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
239
256
  if (hooks && chosen.includes('opencode')) report.push(writeOpenCodePlugin(repo, local, dryRun));
240
257
  if (!noInstructions) {
241
258
  const targets = [...new Set(chosen.map(k => AGENTS[k].instructions))];
242
- for (const t of targets) report.push(`${t}: ${upsertBlock(path.join(repo, t), dryRun)} graph-indexer instructions block`);
259
+ for (const t of targets) report.push(`${t}: ${upsertBlock(repo, path.join(repo, t), dryRun)} graph-indexer instructions block`);
243
260
  }
244
261
  // keep the index out of version control
245
262
  const gi = path.join(repo, '.gitignore');
246
263
  const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
247
264
  if (!/^\/?\.graph-indexer\/?$/m.test(giText)) {
248
- if (!dryRun) fs.writeFileSync(gi, (giText.trimEnd() ? giText.trimEnd() + '\n' : '') + '.graph-indexer/\n');
265
+ if (!dryRun) writeInRepo(repo, gi, (giText.trimEnd() ? giText.trimEnd() + '\n' : '') + '.graph-indexer/\n');
249
266
  report.push('.gitignore: added .graph-indexer/');
250
267
  }
251
268
  process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
252
- `Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args ["-y","graph-indexer@3","serve","--repo","${repo}"].\n` +
253
- `Build the index now with: npx graph-indexer index (the server also builds it on first start)\n` +
269
+ `Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args ${JSON.stringify([...NPX_ARGS, 'serve', '--repo', repo])}.\n` +
270
+ `Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` +
254
271
  (hooks ? '' : `Optional: graph-indexer init --hooks adds Claude Code hooks (edit check after each edit, grep disambiguation).\n`));
255
272
  }
@@ -44,15 +44,25 @@ export function socketPath(root) {
44
44
  return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
45
45
  }
46
46
 
47
- /** A per-user directory in the temp dir must belong to us and be closed to everyone else. */
47
+ const mine = (st) => (process.getuid ? st.uid === process.getuid() : true);
48
+
49
+ /**
50
+ * The socket's directory must be a real directory of ours (not a link the repository committed);
51
+ * a per-user directory in the temp dir must also be closed to everyone else.
52
+ */
48
53
  function privateDir(dir, root) {
49
- if (WIN || dir === path.join(path.resolve(root), DATA_DIR_NAME)) return true;
54
+ if (WIN) return true;
50
55
  try {
51
56
  const st = fs.lstatSync(dir);
52
- return st.isDirectory() && (process.getuid ? st.uid === process.getuid() : true) && (st.mode & 0o077) === 0;
57
+ return st.isDirectory() && mine(st) && (dir === path.join(path.resolve(root), DATA_DIR_NAME) || (st.mode & 0o077) === 0);
53
58
  } catch { return false; }
54
59
  }
55
60
 
61
+ /** A socket of ours, not a file or a listener someone else put there. */
62
+ function ownSocket(file) {
63
+ try { const st = fs.lstatSync(file); return st.isSocket() && mine(st); } catch { return false; }
64
+ }
65
+
56
66
  const disabled = () => process.env.GRAPH_INDEXER_RESIDENT === '0';
57
67
  /** GRAPH_INDEXER_RESIDENT=require: answer only through a resident, never by opening the index (tests, debugging). */
58
68
  export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 'require';
@@ -64,7 +74,7 @@ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 're
64
74
  export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
65
75
  if (disabled()) return Promise.resolve(null);
66
76
  const file = socketPath(root);
67
- if (!WIN && (!fs.existsSync(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
77
+ if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
68
78
  return new Promise((resolve) => {
69
79
  let done = false, connected = false, buf = '';
70
80
  const sock = net.createConnection(file);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { spawnSync } from 'node:child_process';
9
+ import { gitSync } from '../util/git.mjs';
10
10
  import { specForPath } from '../parse/languages.mjs';
11
11
 
12
12
  export const MAX_FILE_BYTES = 1_500_000;
@@ -24,14 +24,12 @@ const SKIP_DIRS = new Set([
24
24
  const GENERATED_FILE = /(\.min\.(js|css|mjs)$|\.bundle\.js$|[.-]bundle\.[cm]?js$|\.chunk\.js$|\.map$|\.pb\.go$|_pb2(_grpc)?\.py$|\.pb\.(cc|h)$|\.generated\.\w+$|\.g\.dart$|\.designer\.cs$|(^|\/)generated\/|\.d\.ts\.map$)/i;
25
25
 
26
26
  function isGitRepo(root) {
27
- const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, encoding: 'utf8' });
27
+ const r = gitSync(root, ['rev-parse', '--is-inside-work-tree']);
28
28
  return r.status === 0 && r.stdout.trim() === 'true';
29
29
  }
30
30
 
31
31
  function gitListFiles(root) {
32
- const r = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
33
- cwd: root, encoding: 'utf8', maxBuffer: 1 << 30,
34
- });
32
+ const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
35
33
  if (r.status !== 0) return null;
36
34
  return r.stdout.split('\0').filter(Boolean);
37
35
  }
@@ -5,12 +5,13 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { readRepoFile } from '../util/paths.mjs';
8
9
 
9
10
  const ECMA_EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
10
11
 
11
- function readJson(p) {
12
+ function readJson(root, rel) {
12
13
  try {
13
- const text = fs.readFileSync(p, 'utf8').replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
14
+ const text = readRepoFile(root, rel).replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
14
15
  return JSON.parse(text);
15
16
  } catch { return null; }
16
17
  }
@@ -53,7 +54,7 @@ export class ModuleResolver {
53
54
  this.packages = new Map();
54
55
  const pkgJsons = [...this.files].length ? this.#findManifests('package.json') : [];
55
56
  for (const rel of pkgJsons) {
56
- const j = readJson(path.join(this.root, rel));
57
+ const j = readJson(this.root, rel);
57
58
  if (j?.name) this.packages.set(j.name, { dir: path.posix.dirname(rel), json: j });
58
59
  }
59
60
  // tsconfig paths and baseUrl, per project: a file resolves through the nearest tsconfig.json
@@ -73,7 +74,7 @@ export class ModuleResolver {
73
74
  this.goModules = [];
74
75
  for (const rel of this.#findManifests('go.mod')) {
75
76
  try {
76
- const m = /^module\s+(\S+)/m.exec(fs.readFileSync(path.join(this.root, rel), 'utf8'));
77
+ const m = /^module\s+(\S+)/m.exec(readRepoFile(this.root, rel) ?? '');
77
78
  if (m) this.goModules.push({ module: m[1], dir: path.posix.dirname(rel) });
78
79
  } catch { /* unreadable */ }
79
80
  }
@@ -82,7 +83,7 @@ export class ModuleResolver {
82
83
 
83
84
  /** compilerOptions of a tsconfig file, following relative `extends` (a child's paths replace its parent's). */
84
85
  #tsOptions(rel, depth = 0) {
85
- const j = readJson(path.join(this.root, rel));
86
+ const j = readJson(this.root, rel);
86
87
  if (!j || depth > 5) return null;
87
88
  const dir = path.posix.dirname(rel);
88
89
  let out = { paths: null, pathsDir: null, baseUrl: null };
@@ -16,8 +16,12 @@
16
16
  import path from 'node:path';
17
17
  import { normalizeType } from '../parse/extract.mjs';
18
18
  import { fits, sigArity } from '../parse/arity.mjs';
19
+ import { escapeRegExp } from '../util/regex.mjs';
19
20
 
20
21
  const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
22
+ // element accesses one descriptor may stack (`xs[i][j]`…): a field assigned an element of itself
23
+ // (`self.impl = self.impl[0]`) would add one on every lookup, with no end
24
+ const MAX_ELEM = 8;
21
25
  const VALUE_TYPED_KINDS = new Set(['variable', 'constant', 'field', 'property']);
22
26
  const CALLABLE_KINDS = new Set(['function', 'method', 'constructor', 'macro']);
23
27
  const MEMBER_HOLDERS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'object', 'impl', 'module']);
@@ -100,7 +104,7 @@ function paramType(param) {
100
104
  */
101
105
  export function callbackParamTypeText(sig, name, i, j) {
102
106
  if (!sig) return null;
103
- const m = new RegExp(`(?:^|[^\\w$])${name.replace(/[$]/g, '\\$')}\\s*(?:<[^()]*>)?\\s*\\(`).exec(sig);
107
+ const m = new RegExp(`(?:^|[^\\w$])${escapeRegExp(name)}\\s*(?:<[^()]*>)?\\s*\\(`).exec(sig);
104
108
  const list = m ? bracketed(sig, m.index + m[0].length - 1) : null;
105
109
  let type = list ? paramType(splitTopLevel(list.inner)[i] ?? '') : null;
106
110
  if (!type) return null;
@@ -376,7 +380,10 @@ export class Resolver {
376
380
  */
377
381
  typeFromString(str, fileId, elem = 0) {
378
382
  if (!str) return null;
379
- if (str.startsWith('call:') || str.startsWith('cb:') || str.includes('#')) return this.resolveRecvType(str + '[]'.repeat(elem), fileId, null);
383
+ if (str.startsWith('call:') || str.startsWith('cb:') || str.includes('#')) {
384
+ const desc = str + '[]'.repeat(elem);
385
+ return desc.split('[]').length - 1 > MAX_ELEM ? null : this.resolveRecvType(desc, fileId, null);
386
+ }
380
387
  let s = str;
381
388
  for (let i = 0; i < elem; i++) {
382
389
  if (!s.endsWith('[]') && !s.endsWith('{}')) {
package/src/mcp/tools.mjs CHANGED
@@ -12,6 +12,7 @@ import { checkChanges } from '../query/check.mjs';
12
12
  import { definitionsUsed, testsUsing } from '../query/read.mjs';
13
13
  import { factsForRange, renderFacts } from '../query/facts.mjs';
14
14
  import fs from 'node:fs';
15
+ import { withinRoot } from '../util/paths.mjs';
15
16
 
16
17
  const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
17
18
  const VALUE_KINDS = new Set(['field', 'property', 'variable', 'constant']);
@@ -421,7 +422,7 @@ async function toolRead(intel, { targets = [], target = null, full = false, max_
421
422
  }
422
423
 
423
424
  function isFile(intel, rel) {
424
- try { return fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
425
+ try { return withinRoot(intel.root, rel) && fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
425
426
  }
426
427
 
427
428
  async function readOne(intel, raw, { full, maxLines, seen }) {
@@ -4,6 +4,7 @@
4
4
  * ({ n, open }, open = a spread/splat makes n a lower bound). Used by the edit checker to spot
5
5
  * call sites that no longer match a changed signature, without compiling anything.
6
6
  */
7
+ import { escapeRegExp } from '../util/regex.mjs';
7
8
 
8
9
  const PARAM_LIST_TYPES = new Set(['formal_parameters', 'parameters', 'parameter_list', 'function_value_parameters', 'method_parameters',
9
10
  'lambda_parameters', 'block_parameters', 'parameter_clause']);
@@ -117,7 +118,7 @@ export function callArgc(call) {
117
118
  */
118
119
  export function sigArity(sig, name) {
119
120
  if (!sig) return null;
120
- const m = new RegExp(`(?:^|[^\\w$])${name.replace(/[$]/g, '\\$')}\\s*(?:<[^()]*>)?\\s*\\(`).exec(sig);
121
+ const m = new RegExp(`(?:^|[^\\w$])${escapeRegExp(name)}\\s*(?:<[^()]*>)?\\s*\\(`).exec(sig);
121
122
  if (!m) return null;
122
123
  let depth = 0, cur = '', min = 0, max = 0, done = false;
123
124
  const param = (p) => {
@@ -334,9 +334,16 @@ export async function extractFile(spec, source, relPath) {
334
334
  }
335
335
  }
336
336
 
337
+ // query time can grow super-linearly with the syntax (60,000 chained calls in a 360 KB file take
338
+ // minutes): such a file is given up after this long instead of stalling the whole index
339
+ const QUERY_BUDGET_MS = 15_000;
340
+
337
341
  function extractFromTree(spec, query, tree, source, relPath) {
338
342
  const root = tree.rootNode;
339
- const matches = query.matches(root);
343
+ const deadline = Date.now() + QUERY_BUDGET_MS;
344
+ let gaveUp = false;
345
+ const matches = query.matches(root, { progressCallback: () => (gaveUp = Date.now() > deadline) });
346
+ if (gaveUp) throw new Error(`gave up after ${QUERY_BUDGET_MS / 1000} s of queries (pathological syntax)`);
340
347
 
341
348
  const defs = []; // { node, nameNode, kind, owner }
342
349
  const rawRefs = []; // { node, nameNode, kind, recvNode }
@@ -11,19 +11,20 @@
11
11
  * inherited subclasses that inherit a changed member (they run the new code) or override it
12
12
  * tests the test files that exercise the changed code, and the command to run them
13
13
  */
14
- import fs from 'node:fs';
15
- import path from 'node:path';
16
- import { spawnSync } from 'node:child_process';
17
14
  import { specForPath, isConstructor } from '../parse/languages.mjs';
18
15
  import { prepareLanguage, extractFile } from '../parse/extract.mjs';
19
16
  import { paramArity, callAt, callArgc, fits, describeArity } from '../parse/arity.mjs';
20
17
  import { testCommands, testCaseCommands } from './testcmd.mjs';
18
+ import { escapeRegExp } from '../util/regex.mjs';
19
+ import { readRepoFile, isRevision } from '../util/paths.mjs';
20
+ import { MAX_FILE_BYTES } from '../index/discover.mjs';
21
+ import { gitSync } from '../util/git.mjs';
21
22
 
22
23
  const CALLABLE = new Set(['function', 'method', 'constructor']);
23
24
  const MEMBER_KINDS = new Set(['class', 'interface', 'struct', 'trait', 'enum', 'object', 'impl', 'module', 'type']);
24
25
 
25
26
  function git(root, args) {
26
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28 });
27
+ const r = gitSync(root, args, { maxBuffer: 1 << 28 });
27
28
  return r.status === 0 ? r.stdout : null;
28
29
  }
29
30
 
@@ -143,6 +144,7 @@ function unionFits(argc, arities) {
143
144
 
144
145
  export async function checkChanges(intel, { base = 'HEAD', files = null, tests = true } = {}) {
145
146
  const root = intel.root;
147
+ if (!isRevision(base)) return { error: `Not a git revision: ${base}` };
146
148
  const changed = changedFiles(root, base);
147
149
  if (!changed) return { error: `git diff against ${base} failed — is this a git repository with that commit?` };
148
150
  // the edited files must be re-indexed now, whatever the file watcher has seen so far
@@ -158,8 +160,8 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
158
160
  let v = null;
159
161
  const spec = specForPath(rel);
160
162
  try {
161
- if (spec) {
162
- const src = fs.readFileSync(path.join(root, rel), 'utf8');
163
+ const src = spec ? readRepoFile(root, rel, MAX_FILE_BYTES) : null;
164
+ if (src !== null) {
163
165
  const { parser } = await prepareLanguage(spec);
164
166
  v = { tree: parser.parse(src), lines: src.split(/\r?\n/), src, spec };
165
167
  }
@@ -276,7 +278,7 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
276
278
  const files = new Map();
277
279
  const mentions = (p, word) => {
278
280
  const key = p + '\u0000' + word;
279
- if (!files.has(key)) { const lines = intel.fileLines(p); files.set(key, !!lines && lines.some(l => new RegExp(`\\b${word.replace(/[$]/g, '\\$')}\\b`).test(l))); }
281
+ if (!files.has(key)) { const lines = intel.fileLines(p); files.set(key, !!lines && lines.some(l => new RegExp(`\\b${escapeRegExp(word)}\\b`).test(l))); }
280
282
  return files.get(key);
281
283
  };
282
284
  rows = rows.filter(r => {
@@ -7,15 +7,16 @@
7
7
  */
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { spawnSync } from 'node:child_process';
11
10
  import { Store } from '../store/db.mjs';
12
11
  import { Indexer } from '../index/indexer.mjs';
13
12
  import { SearchEngine } from '../search/search.mjs';
14
13
  import { computeCentrality, loadGraph } from '../index/graph.mjs';
15
14
  import { confidenceLabel } from '../index/resolver.mjs';
16
- import { dataDir } from '../util/paths.mjs';
15
+ import { dataDir, withinRoot, isRevision } from '../util/paths.mjs';
17
16
  import { isConstructor } from '../parse/languages.mjs';
18
17
  import { sigArity } from '../parse/arity.mjs';
18
+ import { escapeRegExp } from '../util/regex.mjs';
19
+ import { gitSync } from '../util/git.mjs';
19
20
 
20
21
  const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
21
22
  const CALLABLE = new Set(['function', 'method', 'constructor', 'macro']);
@@ -128,6 +129,7 @@ export class CodeIntel {
128
129
 
129
130
  // ── source access ────────────────────────────────────────────────────────────
130
131
  fileLines(rel) {
132
+ if (!withinRoot(this.root, rel)) return null;
131
133
  const abs = path.join(this.root, rel);
132
134
  let st;
133
135
  try { st = fs.statSync(abs); } catch { return null; }
@@ -162,7 +164,7 @@ export class CodeIntel {
162
164
  ORDER BY (s.end_line - s.start_line) ASC LIMIT 1`, file.replace(/^\.\//, ''), Number(line), Number(line));
163
165
  return { matches: rows.map(r => this.sym(r.id)), how: 'location' };
164
166
  }
165
- m = /^(.+?\.[A-Za-z0-9]+)[:#](.+)$/.exec(t);
167
+ m = /^([^:#]+?\.[A-Za-z0-9]+)[:#](.+)$/.exec(t);
166
168
  if (m) {
167
169
  const [, file, q] = m;
168
170
  const rows = this.store.all(`SELECT s.id FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.path = ? AND (s.qname = ? OR s.name = ? OR s.qname LIKE ?) ORDER BY s.qname = ? DESC, s.start_line LIMIT ?`,
@@ -367,7 +369,7 @@ export class CodeIntel {
367
369
  const ids = [...family];
368
370
  for (const { path: p } of this.store.all(`SELECT DISTINCT f.path FROM refs r JOIN files f ON f.id = r.file_id WHERE r.dst_id IN (${ids.map(() => '?').join(',')})`, ...ids)) files.add(p);
369
371
  } else files.add(sym.path);
370
- const name = sym.name.replace(/[$]/g, '\\$');
372
+ const name = escapeRegExp(sym.name);
371
373
  const lit = new RegExp(`(['"\`])(?:[\\w.]*\\.)?${name}\\1`);
372
374
  const title = /^\s*(?:describe|it|test|context|suite)(?:\.\w+)?\s*\(/;
373
375
  const out = [];
@@ -451,7 +453,7 @@ export class CodeIntel {
451
453
  return { total: rows.length, plausible, typeNames: [] };
452
454
  }
453
455
  const names = [...typeNames];
454
- const wordRe = new RegExp(`\\b(${names.map(n => n.replace(/[$]/g, '\\$')).join('|')})\\b`);
456
+ const wordRe = new RegExp(`\\b(${names.map(n => escapeRegExp(n)).join('|')})\\b`);
455
457
  const lower = names.map(n => n.toLowerCase());
456
458
  const mentions = new Map();
457
459
  const plausible = rows.filter(r => {
@@ -575,7 +577,8 @@ export class CodeIntel {
575
577
  // ── impact ───────────────────────────────────────────────────────────────────
576
578
  /** Symbols overlapping the working-tree diff (git), as impact seeds. */
577
579
  diffSeeds(base = 'HEAD') {
578
- const r = spawnSync('git', ['diff', '-U0', '--no-color', base, '--'], { cwd: this.root, encoding: 'utf8', maxBuffer: 1 << 28 });
580
+ if (!isRevision(base)) return { error: `Not a git revision: ${base}`, seeds: [], files: [] };
581
+ const r = gitSync(this.root, ['diff', '-U0', '--no-color', base, '--'], { maxBuffer: 1 << 28 });
579
582
  if (r.status !== 0) return { error: (r.stderr || 'git diff failed').trim(), seeds: [], files: [] };
580
583
  const changes = new Map();
581
584
  let file = null;
@@ -587,7 +590,7 @@ export class CodeIntel {
587
590
  (changes.get(file) ?? changes.set(file, []).get(file)).push([start, Math.max(start, start + len - 1)]);
588
591
  }
589
592
  }
590
- const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: this.root, encoding: 'utf8' });
593
+ const untracked = gitSync(this.root, ['ls-files', '--others', '--exclude-standard']);
591
594
  for (const f of (untracked.stdout || '').split('\n').filter(Boolean)) if (!changes.has(f)) changes.set(f, [[1, 1e9]]);
592
595
  const seeds = new Set();
593
596
  for (const [f, ranges] of changes) {
@@ -604,7 +607,7 @@ export class CodeIntel {
604
607
  /** Files that historically change together with the given files (git log, bounded). */
605
608
  coChange(files, { commits = 400, limit = 8 } = {}) {
606
609
  if (!this._cochange || this._cochangeAt < Date.now() - 600_000) {
607
- const r = spawnSync('git', ['log', `-n${commits}`, '--name-only', '--pretty=format:%x00'], { cwd: this.root, encoding: 'utf8', maxBuffer: 1 << 28 });
610
+ const r = gitSync(this.root, ['log', `-n${commits}`, '--name-only', '--pretty=format:%x00'], { maxBuffer: 1 << 28 });
608
611
  const counts = new Map(); const pair = new Map();
609
612
  if (r.status === 0) {
610
613
  for (const block of r.stdout.split('\0')) {
@@ -6,16 +6,24 @@
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { readRepoFile } from '../util/paths.mjs';
9
10
 
10
- function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
11
+ function readJson(root, rel) { try { return JSON.parse(readRepoFile(root, rel)); } catch { return null; } }
11
12
  const exists = (root, rel) => fs.existsSync(path.join(root, rel));
13
+ // commands are pasted into a shell as they are: a file named `t$(cmd).test.ts` (repository content)
14
+ // must reach the test runner as a name, never run as a command substitution
15
+ const plain = (s) => /^[\w./@+=:,-]+$/.test(s);
16
+ /** One shell word: as is when plain, else single-quoted. */
17
+ const q = (s) => (plain(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
18
+ // a name that would read as an option, or a test identifier that is not a plain word, gets no command
19
+ const shellSafe = (s) => !s.startsWith('-') && !/[\0\r\n]/.test(s);
12
20
 
13
21
  /** Nearest package.json above a file (monorepos have one per package). */
14
22
  function nearestPackage(root, rel) {
15
23
  let dir = path.posix.dirname(rel);
16
24
  for (;;) {
17
- const p = path.join(root, dir === '.' ? '' : dir, 'package.json');
18
- if (fs.existsSync(p)) return { dir: dir === '.' ? '' : dir, pkg: readJson(p) ?? {} };
25
+ const p = path.posix.join(dir === '.' ? '' : dir, 'package.json');
26
+ if (exists(root, p)) return { dir: dir === '.' ? '' : dir, pkg: readJson(root, p) ?? {} };
19
27
  if (dir === '.' || dir === '' || dir === '/') return null;
20
28
  dir = path.posix.dirname(dir);
21
29
  }
@@ -23,7 +31,7 @@ function nearestPackage(root, rel) {
23
31
 
24
32
  function jsRunner(root, rel) {
25
33
  const near = nearestPackage(root, rel);
26
- const top = readJson(path.join(root, 'package.json')) ?? {};
34
+ const top = readJson(root, 'package.json') ?? {};
27
35
  const pkgs = [near?.pkg, top].filter(Boolean);
28
36
  const deps = Object.assign({}, ...pkgs.map(p => ({ ...p.dependencies, ...p.devDependencies })));
29
37
  const scripts = pkgs.map(p => p.scripts?.test ?? '').join(' ');
@@ -38,7 +46,7 @@ function jsRunner(root, rel) {
38
46
 
39
47
  function pyRunner(root) {
40
48
  if (exists(root, 'tests/runtests.py')) return { kind: 'django' };
41
- const pyproject = (() => { try { return fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8'); } catch { return ''; } })();
49
+ const pyproject = readRepoFile(root, 'pyproject.toml') ?? '';
42
50
  if (exists(root, 'pytest.ini') || exists(root, 'conftest.py') || /\[tool\.pytest/.test(pyproject) || exists(root, 'tox.ini')) return { kind: 'pytest' };
43
51
  return { kind: 'unittest' };
44
52
  }
@@ -51,7 +59,7 @@ function pyRunner(root) {
51
59
  export function testCommands(root, files) {
52
60
  const groups = new Map();
53
61
  const add = (key, f) => (groups.get(key) ?? groups.set(key, []).get(key)).push(f);
54
- for (const f of files) {
62
+ for (const f of files.filter(shellSafe)) {
55
63
  const ext = path.posix.extname(f);
56
64
  if (/\.[cm]?[jt]sx?$/.test(ext)) add(`js:${jsRunner(root, f)}`, f);
57
65
  else if (ext === '.py') add('py', f);
@@ -64,19 +72,20 @@ export function testCommands(root, files) {
64
72
  }
65
73
  const out = [];
66
74
  for (const [key, fs_] of groups) {
67
- if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.join(' ')}`);
75
+ if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.map(q).join(' ')}`);
68
76
  else if (key === 'py') {
69
77
  const r = pyRunner(root);
70
- if (r.kind === 'django') out.push(`python tests/runtests.py ${[...new Set(fs_.map(f => f.replace(/^tests\//, '').replace(/\/[^/]*$/, '').replace(/\//g, '.')))].join(' ')}`);
71
- else if (r.kind === 'pytest') out.push(`python -m pytest -q ${fs_.join(' ')}`);
72
- else out.push(`python -m unittest ${fs_.map(f => f.replace(/\.py$/, '').replace(/\//g, '.')).join(' ')}`);
73
- } else if (key === 'go') out.push(`go test ${[...new Set(fs_.map(f => './' + path.posix.dirname(f)))].join(' ')}`);
78
+ if (r.kind === 'django') out.push(`python tests/runtests.py ${[...new Set(fs_.map(f => f.replace(/^tests\//, '').replace(/\/[^/]*$/, '').replace(/\//g, '.')))].map(q).join(' ')}`);
79
+ else if (r.kind === 'pytest') out.push(`python -m pytest -q ${fs_.map(q).join(' ')}`);
80
+ else out.push(`python -m unittest ${fs_.map(f => q(f.replace(/\.py$/, '').replace(/\//g, '.'))).join(' ')}`);
81
+ } else if (key === 'go') out.push(`go test ${[...new Set(fs_.map(f => './' + path.posix.dirname(f)))].map(q).join(' ')}`);
74
82
  else if (key === 'rs') out.push('cargo test');
75
83
  else if (key === 'jvm') {
76
- const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')))].join(',');
84
+ const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')).filter(c => /^\w+$/.test(c)))].join(',');
85
+ if (!classes) continue;
77
86
  out.push(exists(root, 'pom.xml') ? `mvn -q test -Dtest=${classes}` : `./gradlew test ${classes.split(',').map(c => `--tests '*${c}'`).join(' ')}`);
78
- } else if (key === 'rb') out.push(exists(root, '.rspec') || fs_.some(f => f.endsWith('_spec.rb')) ? `bundle exec rspec ${fs_.join(' ')}` : `bundle exec ruby -Itest ${fs_[0]}`);
79
- else if (key === 'php') out.push(`vendor/bin/phpunit ${fs_.join(' ')}`);
87
+ } else if (key === 'rb') out.push(exists(root, '.rspec') || fs_.some(f => f.endsWith('_spec.rb')) ? `bundle exec rspec ${fs_.map(q).join(' ')}` : `bundle exec ruby -Itest ${q(fs_[0])}`);
88
+ else if (key === 'php') out.push(`vendor/bin/phpunit ${fs_.map(q).join(' ')}`);
80
89
  else if (key === 'cs') out.push('dotnet test');
81
90
  }
82
91
  return out;
@@ -90,6 +99,8 @@ export function testCommands(root, files) {
90
99
  */
91
100
  export function testCaseCommands(root, cases) {
92
101
  const out = [];
102
+ const word = (s) => /^\w+$/.test(s);
103
+ cases = cases.filter(c => shellSafe(c.path) && plain(c.path) && word(c.name) && (!c.cls || word(c.cls)));
93
104
  const py = cases.filter(c => c.path.endsWith('.py'));
94
105
  if (py.length) {
95
106
  const r = pyRunner(root);
@@ -7,15 +7,26 @@
7
7
  */
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { spawnSync } from 'node:child_process';
10
+ import vm from 'node:vm';
11
+ import { withinRoot } from '../util/paths.mjs';
12
+ import { gitSync } from '../util/git.mjs';
11
13
 
12
14
  const MAX_FILE_BYTES = 1_500_000;
15
+ // a caller's pattern gets this much matching time per search (reading files does not count): one
16
+ // with catastrophic backtracking (`(a+)+$`) is stopped instead of blocking the server
17
+ const REGEX_BUDGET_MS = 5000;
18
+ const SCAN = new vm.Script(`(() => {
19
+ if (!re.test(text)) return null;
20
+ const lines = text.split(/\\r?\\n/), hits = [];
21
+ for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
22
+ return { lines, hits };
23
+ })()`);
13
24
  const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
14
25
  const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
15
26
  const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
16
27
 
17
28
  function listFiles(root) {
18
- const r = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 30 });
29
+ const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
19
30
  if (r.status === 0) return r.stdout.split('\0').filter(Boolean).filter(p => !SKIP_DIRS.test(p));
20
31
  const out = [];
21
32
  const stack = [''];
@@ -98,25 +109,32 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
98
109
  const indexed = new Map(intel.store.all('SELECT id, path, is_test FROM files').map(f => [f.path, f]));
99
110
  const matches = [];
100
111
  let total = 0, nfiles = 0;
112
+ const ctx = vm.createContext({ re, text: '' });
113
+ let budget = REGEX_BUDGET_MS;
101
114
  for (const rel of files) {
102
115
  let text;
103
116
  try {
117
+ if (!withinRoot(root, rel)) continue;
104
118
  const st = fs.statSync(path.join(root, rel));
105
119
  if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
106
120
  text = fs.readFileSync(path.join(root, rel), 'utf8');
107
121
  } catch { continue; }
108
- if (!re.test(text)) continue;
109
122
  if (text.indexOf('\0') >= 0 && text.indexOf('\0') < 8000) continue; // binary
110
- const lines = text.split(/\r?\n/);
111
- let any = false;
112
- for (let i = 0; i < lines.length; i++) {
113
- if (!re.test(lines[i])) continue;
114
- any = true;
123
+ ctx.text = text;
124
+ let scan;
125
+ const t0 = performance.now();
126
+ try { scan = SCAN.runInContext(ctx, { timeout: Math.max(1, Math.ceil(budget)) }); } catch (e) {
127
+ if (e.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw e;
128
+ return { error: `The regular expression took more than ${REGEX_BUDGET_MS / 1000} s (catastrophic backtracking?). Simplify it, or pass literal: true to search for the text as is.` };
129
+ }
130
+ budget -= performance.now() - t0;
131
+ if (!scan?.hits.length) continue;
132
+ nfiles++;
133
+ const f = indexed.get(rel);
134
+ for (const i of scan.hits) {
115
135
  total++;
116
- const f = indexed.get(rel);
117
- matches.push({ path: rel, line: i + 1, text: lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
136
+ matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
118
137
  }
119
- if (any) nfiles++;
120
138
  }
121
139
  const ident = identifierOf(pattern, literal);
122
140
  // annotate: enclosing definition, and what an identifier match refers to
@@ -0,0 +1,30 @@
1
+ /**
2
+ * git, run so that an indexed repository's own config cannot make it execute anything: no
3
+ * fsmonitor, no hooks, no signature verification, no external diff or textconv, and every
4
+ * clean/smudge/process filter switched off. A repository copied with its .git directory (an
5
+ * archive, a shared folder) is not trusted.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ const COMMAND_KEY = /^(filter\.[^\n]+)\.(clean|smudge|process)$|^diff\.[^\n]+\.(textconv|command)$/;
10
+
11
+ function hardening(root) {
12
+ const args = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'diff.external=', '-c', 'log.showSignature=false'];
13
+ const r = spawnSync('git', ['config', '--name-only', '--get-regexp', '^(filter|diff)\\.'], { cwd: root, encoding: 'utf8' });
14
+ for (const key of (r.status === 0 ? r.stdout : '').split('\n')) {
15
+ const m = COMMAND_KEY.exec(key);
16
+ if (!m) continue;
17
+ args.push('-c', `${key}=`);
18
+ // a required filter (Git LFS sets one) would otherwise fail the diff once its command is blank
19
+ if (m[1]) args.push('-c', `${m[1]}.required=false`);
20
+ }
21
+ return args;
22
+ }
23
+
24
+ const NO_TOOLS = { diff: ['--no-ext-diff', '--no-textconv'], log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'] };
25
+
26
+ /** spawnSync('git', args) in `root`, hardened as above; diff and log never run external tools. */
27
+ export function gitSync(root, args, opts = {}) {
28
+ const sub = NO_TOOLS[args[0]] ? [args[0], ...NO_TOOLS[args[0]], ...args.slice(1)] : args;
29
+ return spawnSync('git', [...hardening(root), ...sub], { cwd: root, encoding: 'utf8', ...opts });
30
+ }
@@ -6,7 +6,12 @@ export const DATA_DIR_NAME = '.graph-indexer';
6
6
 
7
7
  export function dataDir(root) {
8
8
  const dir = path.join(root, DATA_DIR_NAME);
9
- fs.mkdirSync(dir, { recursive: true });
9
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
10
+ // a .graph-indexer the repository committed as a link would put the index (excerpts of the
11
+ // source) wherever it points
12
+ const st = fs.lstatSync(dir);
13
+ if (!st.isDirectory()) throw new Error(`${dir} is not a directory (a symbolic link?); remove it and run again`);
14
+ if ((st.mode & 0o077) !== 0) { try { fs.chmodSync(dir, 0o700); } catch { /* read-only */ } }
10
15
  const gi = path.join(dir, '.gitignore');
11
16
  if (!fs.existsSync(gi)) { try { fs.writeFileSync(gi, '*\n'); } catch { /* read-only */ } }
12
17
  return dir;
@@ -22,3 +27,37 @@ export function findRepoRoot(start) {
22
27
  dir = parent;
23
28
  }
24
29
  }
30
+
31
+ const realRoots = new Map();
32
+
33
+ /**
34
+ * `rel` names a file inside the repository once `..` and symlinks are resolved, so a tool target
35
+ * like `../../etc/hosts` or a tracked link to ~/.ssh/… is never read. The index's own data and git's
36
+ * internals are not served either.
37
+ */
38
+ export function withinRoot(root, rel) {
39
+ let realRoot = realRoots.get(root);
40
+ if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
41
+ let real;
42
+ try { real = fs.realpathSync(path.join(root, rel)); } catch { return false; }
43
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return false;
44
+ const top = path.relative(realRoot, real).split(path.sep)[0];
45
+ return top !== '.git' && top !== DATA_DIR_NAME;
46
+ }
47
+
48
+ /** A git revision given by a caller: never an option (`--output=…`), whitespace or NUL. */
49
+ export const isRevision = (s) => typeof s === 'string' && s.length > 0 && s.length <= 256 && !s.startsWith('-') && !/[\s\0]/.test(s);
50
+
51
+ /**
52
+ * A small regular file of the repository (a manifest, a config), or null: never a device, a FIFO,
53
+ * a file larger than `maxBytes` or a link out of the root — a `tsconfig.json` linked to /dev/zero
54
+ * would otherwise hang the indexer.
55
+ */
56
+ export function readRepoFile(root, rel, maxBytes = 2_000_000) {
57
+ if (!withinRoot(root, rel)) return null;
58
+ try {
59
+ const abs = path.join(root, rel);
60
+ const st = fs.statSync(abs);
61
+ return st.isFile() && st.size <= maxBytes ? fs.readFileSync(abs, 'utf8') : null;
62
+ } catch { return null; }
63
+ }
@@ -0,0 +1,2 @@
1
+ /** A string as a literal inside a regular expression: every metacharacter escaped. */
2
+ export const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');