graph-indexer 3.1.0 → 3.1.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.1.1] — 2026-11-13
8
+
9
+ A security release. Upgrading is recommended for everyone; nothing else changes. Most of these need a
10
+ repository that was copied together with its `.git` directory (an archive, a shared folder) or that
11
+ commits symbolic links; one needs only a hostile tool call.
12
+
13
+ ### Security
14
+
15
+ - **git runs nothing the indexed repository's config names, on every call.** Two git calls bypassed
16
+ the hardening added in 3.0.1, so a repository with `core.fsmonitor` set in its `.git/config` ran
17
+ that command: the post-tool hook's `git status` (after every shell command the agent ran) and the
18
+ `models` view's `git ls-files`. Every git call now goes through one hardened function.
19
+ - **Filter and diff drivers named with `=` are switched off too.** git splits a `-c` argument at the
20
+ first `=`, so the override for a driver named `x=y` set another key and left its clean filter or
21
+ textconv command on, running it from `check_changes`, `change_impact` and the hook. The overrides
22
+ now go through git's environment (`GIT_CONFIG_COUNT`), which holds any name; a git older than 2.31
23
+ is not run at all on such a repository. git no longer descends into submodules, whose own config
24
+ the overrides do not cover.
25
+ - **`.git/` is not served under another letter case.** On a case-insensitive file system (macOS,
26
+ Windows) `read_code ".GIT/config"` read `.git/config`, which can hold credentials in remote URLs.
27
+ - **Nothing is written through links committed inside `.graph-indexer/`.** A repository that
28
+ shipped `.graph-indexer/index.db-wal` or `resident.started` as a link to `~/.zshrc` had that file
29
+ overwritten. Links inside the directory are now removed before anything is written, and an index
30
+ committed to the repository is not opened (the database also runs in SQLite's defensive mode with
31
+ `trusted_schema` off).
32
+ - **Files under a linked directory stay out.** A tracked `link/secret.ts`, where `link` points
33
+ outside the repository, was indexed and served, because only the last component of a path was
34
+ checked. Every component is now resolved, and devices and FIFOs are skipped instead of read.
35
+ - **`Cargo.toml`, `svelte.config.*`, `pom.xml`, `build.gradle`, `.gitignore`, Prisma schemas and
36
+ migrations are read only as small regular files inside the repository**, like `package.json` and
37
+ `tsconfig.json` already were: a `Cargo.toml` linked to `/dev/zero` grew the indexer past 5 GB, and a
38
+ `schema.prisma` linked to a file elsewhere was read and shown.
39
+ - **`find_files` stops a glob that backtracks catastrophically** (`*a*a*a*a*a*a*a*a*b`: 97 s on one
40
+ file name) after 5 s, as `search_text` does for regular expressions.
41
+ - **`.gitignore` patterns are matched without backtracking** (repositories without git), and
42
+ `search_text` annotates its matches in linear time and keeps at most 50,000 of them: 10,000 matches
43
+ in one file took 14 s, now about 1 s.
44
+ - **Tool arguments are held to their schema's bounds** (`depth`, `limit`, list lengths) whatever
45
+ the MCP client sends.
46
+ - **Windows: nothing named `git`, `npx` or `graph-indexer` is run from the repository.** A bare
47
+ command is looked up in the working directory first there, so a committed `git.exe` or `npx.cmd`
48
+ ran. git is looked up on PATH only; the Claude Code and OpenCode plugins start npx from the home
49
+ directory; `init` writes npx's absolute path; and `init` no longer takes a `graph-indexer` found
50
+ through a relative or `node_modules/.bin` PATH entry.
51
+ - **Windows: another user cannot answer for the resident.** The named pipe's name now carries a
52
+ random id kept in `.graph-indexer/`, so it cannot be created first by someone who only knows the
53
+ repository's path.
54
+ - `SECURITY.md` now states the one network access there is (the opt-in, hash-checked
55
+ `semantic install`) and lists every file read outside the index.
56
+
57
+ ---
58
+
7
59
  ## [3.1.0] — 2026-11-08
8
60
 
9
61
  ### Added
package/SECURITY.md CHANGED
@@ -25,32 +25,43 @@ are not maintained; please upgrade to the latest version.
25
25
 
26
26
  graph-indexer is a local developer tool. Its threat model follows from a few deliberate choices:
27
27
 
28
- - **No network.** graph-indexer makes no outbound connections: no telemetry, no model
29
- downloads, no grammar installs. The tree-sitter runtime and all grammars ship inside the
30
- package as WebAssembly (versions and SHA-256 checksums in `vendor/grammars/manifest.json`),
31
- so there is no install-time or first-run download and no native build step.
28
+ - **No network.** graph-indexer makes no outbound connections: no telemetry, no grammar
29
+ installs. The tree-sitter runtime and all grammars ship inside the package as WebAssembly
30
+ (versions and SHA-256 checksums in `vendor/grammars/manifest.json`), so there is no install-time
31
+ or first-run download and no native build step. The one exception is explicit and opt-in:
32
+ `graph-indexer semantic install` downloads the optional embedding model from huggingface.co at a
33
+ pinned revision and installs it only if every file's SHA-256 matches the digest in the source.
32
34
  - **No dependencies.** The package has no npm dependencies; it uses only Node.js built-ins
33
35
  (`node:sqlite`, `node:fs`, `node:child_process`, …) and the vendored WebAssembly runtime.
34
36
  - **No code execution.** Source files are parsed into syntax trees; the indexed code is never
35
37
  imported, evaluated or run. The only subprocesses are read-only git commands
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.
38
+ (`git rev-parse`, `git ls-files`, `git status`, `git diff`, `git log`, `git show`) run in the
39
+ indexed repository with fixed arguments, all through one function (`src/util/git.mjs`). The only
40
+ tool input they take is the `base` revision of `check_changes`, which is refused when git could
41
+ read it as an option. Every git call switches off what the repository's own git config could
42
+ run: `core.fsmonitor`, hooks, signature verification, clean/smudge/process filters, `diff.external`
43
+ and textconv drivers. The overrides are passed through git's environment (`GIT_CONFIG_COUNT`), which
44
+ holds any driver name verbatim, including one containing `=`; with a git older than 2.31 that
45
+ cannot, a repository naming such a driver gets no git call at all. git does not descend into
46
+ submodules (`--ignore-submodules=all`), whose own config the overrides do not cover. On Windows,
47
+ `git` and `npx` are looked up on PATH only, never in the repository (the working directory).
41
48
  - **No code from the repository through npx.** Every `npx` command graph-indexer runs or writes into
42
49
  an agent's configuration passes `--prefix`, so a `node_modules/graph-indexer` that the repository
43
50
  ships is never run in place of the published package.
44
51
  - **Stays inside the repository.** Only files listed by git (or found by a `.gitignore`-aware
45
- walk) under the repository root are indexed. Symlinks are followed only when their target
46
- resolves inside the repository, so a link to a file elsewhere on the machine is never read,
47
- indexed or served. Paths given to the tools (`read_code "file.ts:42"`, `outline`,
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.
52
+ walk) under the repository root are indexed. Every component of a file's path is resolved, so
53
+ neither a symbolic link to a file elsewhere on the machine nor a file under a linked directory is
54
+ read, indexed or served; devices and FIFOs are skipped. Paths given to the tools
55
+ (`read_code "file.ts:42"`, `outline`, `change_impact`) are resolved the same way and must stay
56
+ inside the repository; `.git/` and `.graph-indexer/` are never served, in any letter case on a
57
+ case-insensitive file system. Manifests and other files read outside the index (`package.json`,
58
+ `tsconfig.json`, `go.mod`, `Cargo.toml`, `svelte.config.*`, `pom.xml`, `build.gradle`, `.gitignore`,
59
+ Prisma schemas, migrations) are read only as small regular files inside the repository.
60
+ - **Bounded work.** A regular expression given to `search_text` and a glob given to `find_files`
61
+ run under a 5 s budget, `.gitignore` patterns are matched without backtracking, `search_text`
62
+ annotates at most 50,000 matching lines, and a file whose syntax makes the structural queries
63
+ super-linear is given up after 15 s, so none of them can block the server. Tool arguments are held
64
+ to the bounds their schema declares (depths, limits, list lengths) whatever the client sends.
54
65
  - **Read-only tools.** All MCP tools are read-only (`readOnlyHint: true`) and never modify
55
66
  the repository.
56
67
  - **stdio transport.** The MCP server talks to the local client over stdin/stdout. It does not
@@ -59,10 +70,14 @@ graph-indexer is a local developer tool. Its threat model follows from a few del
59
70
  hooks, also answers hook and CLI queries over a Unix domain socket (a named pipe on Windows),
60
71
  never TCP. The socket lives in `<repo>/.graph-indexer/` with mode 0600; when that path is too
61
72
  long for a socket, in a per-user directory in the temp dir with mode 0700 whose owner and mode
62
- both sides check before using it. It answers the same read-only queries as the CLI.
73
+ both sides check before using it. On Windows the pipe's name carries a random id that each
74
+ resident writes to `.graph-indexer/`, so another user of the machine cannot create the pipe first
75
+ and answer in its place. It answers the same read-only queries as the CLI.
63
76
  `GRAPH_INDEXER_RESIDENT=0` disables it.
64
77
  - **Local artifacts.** The index is a SQLite database in `<repo>/.graph-indexer/` (git-ignored by
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
78
+ `init`, mode 0700; a `.graph-indexer` that is a symbolic link is refused, symbolic links inside it
79
+ are removed before anything is written there, and an index committed to the repository is not
80
+ opened). It contains excerpts of your source code — treat it with the same sensitivity as the
66
81
  repository itself and do not commit or share it. Deleting the directory is always safe; it is
67
82
  rebuilt on the next run.
68
83
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.1.0",
3
+ "version": "3.1.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",
@@ -12,13 +12,13 @@ allow plugins to start local programs, so the user must approve this step.
12
12
  2. Ask the user to confirm, then run from the repository root:
13
13
 
14
14
  ```bash
15
- claude mcp add graph-indexer -- npx -y --prefix / graph-indexer@3.1.0 serve
15
+ claude mcp add graph-indexer -- npx -y --prefix / graph-indexer@3.1.1 serve
16
16
  ```
17
17
 
18
18
  `--prefix /` makes npx run only the published package, never a copy shipped inside the repository.
19
19
  The version is pinned on purpose: update it by hand when a newer graph-indexer is released.
20
20
 
21
- 3. Optional, with the user's consent: `npx -y --prefix / graph-indexer@3.1.0 init --hooks` also writes the
21
+ 3. Optional, with the user's consent: `npx -y --prefix / graph-indexer@3.1.1 init --hooks` also writes the
22
22
  Claude Code hooks (an edit check after each edit, grep disambiguation) to `.claude/settings.json`.
23
23
  4. Restart Claude Code, or run `/mcp`, and confirm `graph-indexer` is connected.
24
24
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.1.0",
3
+ "version": "3.1.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",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "graph-indexer": {
4
4
  "command": "npx",
5
- "args": ["-y", "--prefix", "/", "graph-indexer@3.1.0", "serve"]
5
+ "args": ["-y", "--prefix", "/", "graph-indexer@3.1.1", "serve"]
6
6
  }
7
7
  }
8
8
  }
@@ -3,7 +3,7 @@
3
3
  This folder is the plugin with everything wired in: the MCP server (`.mcp.json`), the hooks
4
4
  (`hooks/`) and the `code-structure` sub-agent. The plugin directory lists only plugins made of
5
5
  Markdown and JSON (see `../claude-code`), so this one is not listed there. To use it, copy the
6
- folder or point a local marketplace at it. `npx -y --prefix / graph-indexer@3.1.0 init --hooks`
6
+ folder or point a local marketplace at it. `npx -y --prefix / graph-indexer@3.1.1 init --hooks`
7
7
  gives the same result without a plugin.
8
8
 
9
9
  Each release must update the version in `.mcp.json`, `hooks/gi-hook.mjs` and
@@ -21,7 +21,7 @@ import { pathToFileURL } from 'node:url';
21
21
 
22
22
  const PROTOCOL = 1;
23
23
  // the exact release this plugin version runs through npx (never a range): keep it equal to package.json's version
24
- export const VERSION = '3.1.0';
24
+ export const VERSION = '3.1.1';
25
25
  const WIN = process.platform === 'win32';
26
26
 
27
27
  export function findRepoRoot(start) {
@@ -37,7 +37,13 @@ export function findRepoRoot(start) {
37
37
  export function socketPath(root) {
38
38
  const abs = path.resolve(root);
39
39
  const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
40
- if (WIN) return `\\\\.\\pipe\\graph-indexer-${hash}`;
40
+ // a pipe name anyone could compute would let another user of the machine create it first and
41
+ // answer as the resident: the name carries a random id kept in the private data directory
42
+ if (WIN) {
43
+ let id = '';
44
+ try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
45
+ return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
46
+ }
41
47
  const inRepo = path.join(abs, '.graph-indexer', 'resident.sock');
42
48
  if (Buffer.byteLength(inRepo) <= 100) return inRepo;
43
49
  return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
@@ -57,7 +63,7 @@ function trusted(file, root) {
57
63
  /** The resident's answer ({ out }) or null. `any: true`: this client has no logic of its own to match. */
58
64
  function ask(root, request, totalMs) {
59
65
  const file = socketPath(root);
60
- if (!trusted(file, root)) return Promise.resolve(null);
66
+ if (!file || !trusted(file, root)) return Promise.resolve(null);
61
67
  return new Promise((resolve) => {
62
68
  let done = false, buf = '';
63
69
  const sock = net.createConnection(file);
@@ -91,8 +97,11 @@ async function main() {
91
97
  // no resident yet: the full hook through npx (it starts one for the next calls); --prefix keeps
92
98
  // npx from running a graph-indexer that the repository ships in its own node_modules
93
99
  // (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
94
- const home = WIN ? `"${os.homedir()}"` : os.homedir();
95
- const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home, `graph-indexer@${VERSION}`, 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
100
+ // It runs from the home directory with the repository passed explicitly: cmd.exe looks for
101
+ // npx.cmd in the working directory first, which here would be the repository's.
102
+ if (WIN && /["%\r\n]/.test(root)) return;
103
+ const q = (a) => (WIN ? `"${a}"` : a);
104
+ const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', q(os.homedir()), `graph-indexer@${VERSION}`, 'hook', event, '--repo', q(root)], { cwd: os.homedir(), stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
96
105
  child.on('error', () => { /* fail open */ });
97
106
  child.stdin.on('error', () => { /* fail open */ });
98
107
  child.stdin.end(raw);
@@ -39,7 +39,13 @@ function findRepoRoot(start) {
39
39
  function socketPath(root) {
40
40
  const abs = path.resolve(root);
41
41
  const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
42
- if (WIN) return `\\\\.\\pipe\\graph-indexer-${hash}`;
42
+ // a pipe name anyone could compute would let another user of the machine create it first and
43
+ // answer as the resident: the name carries a random id kept in the private data directory
44
+ if (WIN) {
45
+ let id = '';
46
+ try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
47
+ return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
48
+ }
43
49
  const inRepo = path.join(abs, '.graph-indexer', 'resident.sock');
44
50
  if (Buffer.byteLength(inRepo) <= 100) return inRepo;
45
51
  return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
@@ -58,7 +64,7 @@ function trusted(file, root) {
58
64
 
59
65
  function ask(root, request, totalMs) {
60
66
  const file = socketPath(root);
61
- if (!trusted(file, root)) return Promise.resolve(null);
67
+ if (!file || !trusted(file, root)) return Promise.resolve(null);
62
68
  return new Promise((resolve) => {
63
69
  let done = false, buf = '';
64
70
  const sock = net.createConnection(file);
@@ -112,7 +118,8 @@ export const GraphIndexer = async ({ directory, worktree } = {}) => {
112
118
  // every argument, and give up on one that a quote cannot hold
113
119
  const args = [...DAEMON.slice(1), '--repo', root];
114
120
  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 });
121
+ // from the home directory: cmd.exe would look for npx.cmd in the repository first
122
+ const child = spawn(DAEMON[0], WIN ? args.map(a => `"${a}"`) : args, { cwd: os.homedir(), detached: true, stdio: 'ignore', windowsHide: true, shell: WIN });
116
123
  child.on('error', () => { /* optional */ });
117
124
  child.unref();
118
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.1.0",
3
+ "version": "3.1.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",
package/src/cli/hook.mjs CHANGED
@@ -29,9 +29,10 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
- import { spawn, spawnSync } from 'node:child_process';
32
+ import { spawn } from 'node:child_process';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.mjs';
35
+ import { gitSync } from '../util/git.mjs';
35
36
  import { codeSpecForPath as specForPath } from '../parse/languages.mjs'; // hooks report on code files only
36
37
 
37
38
  const MAX_CONTEXT = 1500;
@@ -333,7 +334,8 @@ async function afterRead(root, input, state, crawling, acquire) {
333
334
  */
334
335
  function shellEdits(root, state) {
335
336
  let r;
336
- try { r = spawnSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all', '-z'], { encoding: 'utf8', timeout: 1500 }); } catch { return []; }
337
+ // hardened (gitSync): a repository's core.fsmonitor or filters would otherwise run
338
+ try { r = gitSync(root, ['status', '--porcelain', '--untracked-files=all', '-z'], { timeout: 1500 }); } catch { return []; }
337
339
  if (r.status !== 0 || typeof r.stdout !== 'string') return [];
338
340
  const seen = state.st.mtimes ??= {};
339
341
  const changed = [];
package/src/cli/init.mjs CHANGED
@@ -58,13 +58,23 @@ function readJson(p) {
58
58
  // (code the repository ships) instead of the published package
59
59
  const NPX_ARGS = ['-y', '--prefix', '/', 'graph-indexer@3'];
60
60
 
61
+ /**
62
+ * npx, by absolute path on Windows: there a bare `npx` is looked up in the working directory (the
63
+ * repository) before PATH, so an `npx.cmd`/`npx.exe` the repository ships would run instead.
64
+ */
65
+ function npxCommand() {
66
+ if (process.platform !== 'win32') return 'npx';
67
+ const f = path.join(path.dirname(process.execPath), 'npx.cmd');
68
+ return fs.existsSync(f) ? f : 'npx';
69
+ }
70
+
61
71
  function serverEntry(agent, { local }) {
62
72
  const here = path.dirname(fileURLToPath(import.meta.url));
63
73
  const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
64
74
  const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
65
75
  const base = local
66
76
  ? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
67
- : { command: 'npx', args: [...NPX_ARGS, 'serve', ...repoArgs] };
77
+ : { command: npxCommand(), args: [...NPX_ARGS, 'serve', ...repoArgs] };
68
78
  if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
69
79
  if (agent.zed) return { ...base, env: {} };
70
80
  return agent.vscode ? { type: 'stdio', ...base } : base;
@@ -110,7 +120,9 @@ function upsertBlock(repo, file, dryRun) {
110
120
  /** An installed `graph-indexer` executable on PATH, if any (hooks start in ~0.1 s instead of npx's ~1 s). */
111
121
  function installedBin() {
112
122
  for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
113
- if (!dir) continue;
123
+ // only absolute entries, never a project's own bin directory: a PATH with `.` or
124
+ // `node_modules/.bin` would otherwise find a graph-indexer the repository ships
125
+ if (!dir || !path.isAbsolute(dir) || /(^|[\\/])node_modules[\\/]\.bin[\\/]?$/.test(dir)) continue;
114
126
  const f = path.join(dir, process.platform === 'win32' ? 'graph-indexer.cmd' : 'graph-indexer');
115
127
  try { if (fs.statSync(f).isFile()) return f; } catch { /* not here */ }
116
128
  }
@@ -123,7 +135,7 @@ function cliCommand(local) {
123
135
  const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
124
136
  if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
125
137
  const installed = installedBin();
126
- return installed ? JSON.stringify(installed) : ['npx', ...NPX_ARGS].join(' ');
138
+ return installed ? JSON.stringify(installed) : [npxCommand() === 'npx' ? 'npx' : JSON.stringify(npxCommand()), ...NPX_ARGS].join(' ');
127
139
  }
128
140
 
129
141
  /** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
@@ -131,7 +143,7 @@ function cliArgv(local) {
131
143
  const here = path.dirname(fileURLToPath(import.meta.url));
132
144
  if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
133
145
  const installed = installedBin();
134
- return installed ? [installed] : ['npx', ...NPX_ARGS];
146
+ return installed ? [installed] : [npxCommand(), ...NPX_ARGS];
135
147
  }
136
148
 
137
149
  /**
@@ -37,7 +37,13 @@ const WIN = process.platform === 'win32';
37
37
  export function socketPath(root) {
38
38
  const abs = path.resolve(root);
39
39
  const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
40
- if (WIN) return `\\\\.\\pipe\\graph-indexer-${hash}`;
40
+ // a pipe name anyone could compute would let another user of the machine create it first and
41
+ // answer as the resident: the name carries a random id kept in the private data directory
42
+ if (WIN) {
43
+ let id = '';
44
+ try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
45
+ return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
46
+ }
41
47
  const inRepo = path.join(abs, DATA_DIR_NAME, 'resident.sock');
42
48
  // Unix socket paths are limited to 104–108 bytes
43
49
  if (Buffer.byteLength(inRepo) <= 100) return inRepo;
@@ -74,7 +80,7 @@ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 're
74
80
  export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
75
81
  if (disabled()) return Promise.resolve(null);
76
82
  const file = socketPath(root);
77
- if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
83
+ if (!file || (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root)))) return Promise.resolve(null);
78
84
  return new Promise((resolve) => {
79
85
  let done = false, connected = false, buf = '';
80
86
  const sock = net.createConnection(file);
@@ -114,7 +120,7 @@ export function askResident(root, request, { version, connectMs = 250, totalMs =
114
120
  */
115
121
  export async function startResident({ root, version, handle, log = () => {}, idleMs = 0, onIdle = null, wanted = () => true }) {
116
122
  if (disabled()) return null;
117
- const file = socketPath(root);
123
+ let file = socketPath(root);
118
124
  if (!WIN) {
119
125
  const dir = path.dirname(file);
120
126
  try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* checked below */ }
@@ -123,6 +129,15 @@ export async function startResident({ root, version, handle, log = () => {}, idl
123
129
  // another resident may serve this repository already; a dead one leaves its socket file behind
124
130
  if (await askResident(root, { op: 'ping' }, { version, connectMs: 300, totalMs: 1500 })) return null;
125
131
  if (!WIN) { try { fs.unlinkSync(file); } catch { /* none */ } }
132
+ else {
133
+ // a fresh pipe id for this resident (see socketPath)
134
+ const idFile = path.join(path.resolve(root), DATA_DIR_NAME, 'resident.pipe-id');
135
+ try {
136
+ fs.mkdirSync(path.dirname(idFile), { recursive: true });
137
+ fs.writeFileSync(idFile, crypto.randomBytes(16).toString('hex'), { mode: 0o600 });
138
+ } catch (e) { log(`resident: cannot write ${idFile} (${e.message}); not listening`); return null; }
139
+ file = socketPath(root);
140
+ }
126
141
 
127
142
  let last = Date.now();
128
143
  const server = net.createServer((sock) => {
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { gitSync } from '../util/git.mjs';
10
+ import { readRepoFile, withinRoot } from '../util/paths.mjs';
10
11
  import { specForPath } from '../parse/languages.mjs';
11
12
 
12
13
  export const MAX_FILE_BYTES = 1_500_000;
@@ -34,26 +35,56 @@ function gitListFiles(root) {
34
35
  return r.stdout.split('\0').filter(Boolean);
35
36
  }
36
37
 
38
+ /**
39
+ * A .gitignore pattern as a matcher that cannot backtrack: `*` (within a name), `?`, `**` (across
40
+ * names), anything else literal. A regular expression of many stars (`*a*a*a*a*a*b`) takes seconds
41
+ * per long path; this keeps the set of positions reachable after each token, so a match costs
42
+ * pattern length × path length. Unanchored, it may start at any name; it must end at the end of
43
+ * the path or at a `/` (a directory and everything under it).
44
+ */
45
+ function globMatcher(glob, anchored) {
46
+ const tokens = [];
47
+ for (let i = 0; i < glob.length; i++) {
48
+ if (glob[i] === '*') {
49
+ let j = i; while (glob[j + 1] === '*') j++;
50
+ tokens.push(j > i ? '**' : '*'); i = j;
51
+ } else tokens.push(glob[i] === '?' ? '?' : { c: glob[i] });
52
+ }
53
+ return (s) => {
54
+ const n = s.length;
55
+ let cur = new Uint8Array(n + 1);
56
+ cur[0] = 1;
57
+ if (!anchored) for (let k = 0; k < n; k++) if (s[k] === '/') cur[k + 1] = 1;
58
+ for (const t of tokens) {
59
+ const next = new Uint8Array(n + 1);
60
+ let any = false;
61
+ if (t === '**') { let on = 0; for (let p = 0; p <= n; p++) { on |= cur[p]; next[p] = on; any ||= !!on; } }
62
+ else if (t === '*') { let on = 0; for (let p = 0; p <= n; p++) { on |= cur[p]; next[p] = on; any ||= !!on; if (s[p] === '/') on = 0; } }
63
+ else if (t === '?') { for (let p = 0; p < n; p++) if (cur[p] && s[p] !== '/') { next[p + 1] = 1; any = true; } }
64
+ else { for (let p = 0; p < n; p++) if (cur[p] && s[p] === t.c) { next[p + 1] = 1; any = true; } }
65
+ if (!any) return false;
66
+ cur = next;
67
+ }
68
+ for (let p = 0; p <= n; p++) if (cur[p] && (p === n || s[p] === '/')) return true;
69
+ return false;
70
+ };
71
+ }
72
+
37
73
  /** Minimal .gitignore → predicate (used only outside git repos). */
38
74
  function loadGitignore(root) {
39
75
  const rules = [];
40
- try {
41
- for (let line of fs.readFileSync(path.join(root, '.gitignore'), 'utf8').split(/\r?\n/)) {
42
- line = line.trim();
43
- if (!line || line.startsWith('#')) continue;
44
- const neg = line.startsWith('!');
45
- if (neg) line = line.slice(1);
46
- const dirOnly = line.endsWith('/');
47
- if (dirOnly) line = line.slice(0, -1);
48
- const anchored = line.startsWith('/') || line.includes('/');
49
- line = line.replace(/^\//, '');
50
- const re = line.split('**').map(seg => seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')).join('.*');
51
- rules.push({ neg, re: new RegExp(anchored ? `^${re}(/|$)` : `(^|/)${re}(/|$)`) });
52
- }
53
- } catch { /* no .gitignore */ }
76
+ for (let line of (readRepoFile(root, '.gitignore') ?? '').split(/\r?\n/)) {
77
+ line = line.trim();
78
+ if (!line || line.startsWith('#')) continue;
79
+ const neg = line.startsWith('!');
80
+ if (neg) line = line.slice(1);
81
+ if (line.endsWith('/')) line = line.slice(0, -1);
82
+ const anchored = line.startsWith('/') || line.includes('/');
83
+ rules.push({ neg, test: globMatcher(line.replace(/^\//, ''), anchored) });
84
+ }
54
85
  return (rel) => {
55
86
  let ignored = false;
56
- for (const r of rules) if (r.re.test(rel)) ignored = !r.neg;
87
+ for (const r of rules) if (r.test(rel)) ignored = !r.neg;
57
88
  return ignored;
58
89
  };
59
90
  }
@@ -95,28 +126,19 @@ export function discoverFiles(root, opts = {}) {
95
126
  viaGit = rels !== null;
96
127
  }
97
128
  if (!rels) rels = walk(root, loadGitignore(root));
98
- const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) && (!viaGit || insideRoot(root, r)));
129
+ const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) && insideRoot(root, r));
99
130
  files.sort();
100
131
  return { files, viaGit };
101
132
  }
102
133
 
103
- const realRoots = new Map();
104
-
105
134
  /**
106
- * A tracked file must resolve inside the repository: symlinks are followed only when their target
107
- * stays under the root, so a link to /etc/… or ~/.ssh/… is never read, indexed or served.
135
+ * A tracked file must resolve inside the repository: every component of its path is resolved, so
136
+ * neither a link to /etc/… or ~/.ssh/… nor a file under a linked directory (`link/secret.ts` with
137
+ * `link` → elsewhere, which git lists from its index without looking) is read, indexed or served.
108
138
  */
109
139
  export function insideRoot(root, rel) {
110
- const abs = path.join(root, rel);
111
- let st;
112
- try { st = fs.lstatSync(abs); } catch { return true; } // vanished: the caller handles deletion
113
- if (!st.isSymbolicLink()) return true;
114
- let realRoot = realRoots.get(root);
115
- if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = root; } realRoots.set(root, realRoot); }
116
- try {
117
- const target = fs.realpathSync(abs);
118
- return target === realRoot || target.startsWith(realRoot + path.sep);
119
- } catch { return false; } // dangling link
140
+ try { fs.lstatSync(path.join(root, rel)); } catch { return true; } // vanished: the caller handles deletion
141
+ return withinRoot(root, rel);
120
142
  }
121
143
 
122
144
  /** Heuristic: minified / generated content that slipped past the name filters. */
@@ -98,7 +98,7 @@ export class Indexer {
98
98
  const toParse = [];
99
99
  for (const rel of files) {
100
100
  const st = safeStat(path.join(this.root, rel));
101
- if (!st || st.size > MAX_FILE_BYTES) { if (known.has(rel)) removed.push(rel); continue; }
101
+ if (!st || !st.isFile() || st.size > MAX_FILE_BYTES) { if (known.has(rel)) removed.push(rel); continue; }
102
102
  const k = known.get(rel);
103
103
  if (k && k.size === st.size && k.mtime_ms === Math.floor(st.mtimeMs)) continue;
104
104
  toParse.push({ rel, st, prev: k ?? null });
@@ -86,8 +86,7 @@ export class ModuleResolver {
86
86
  // .svelte-kit/tsconfig.json that a repository does not carry, so read svelte.config itself
87
87
  for (const rel of [...this.#findManifests('svelte.config.js'), ...this.#findManifests('svelte.config.ts')]) {
88
88
  const dir = path.posix.dirname(rel), at = (p) => path.posix.normalize(dir === '.' ? p : `${dir}/${p}`);
89
- let text = '';
90
- try { text = fs.readFileSync(path.join(this.root, rel), 'utf8'); } catch { /* unreadable */ }
89
+ const text = readRepoFile(this.root, rel) ?? '';
91
90
  const lib = /\bfiles\s*:\s*\{[^}]*\blib\s*:\s*['"]([^'"]+)['"]/.exec(text)?.[1] ?? 'src/lib';
92
91
  const aliases = [['$lib', lib]];
93
92
  const block = /\balias\s*:\s*\{([^}]*)\}/.exec(text)?.[1] ?? '';
@@ -110,8 +109,8 @@ export class ModuleResolver {
110
109
  this.rustCrates = new Map();
111
110
  this.rustPackages = [];
112
111
  for (const rel of this.#findManifests('Cargo.toml')) {
113
- let text;
114
- try { text = fs.readFileSync(path.join(this.root, rel), 'utf8'); } catch { continue; }
112
+ const text = readRepoFile(this.root, rel);
113
+ if (text == null) continue;
115
114
  const dir = path.posix.dirname(rel), at = (p) => (dir === '.' ? p : `${dir}/${p}`);
116
115
  if (/^\[package\]\s*$/m.test(text)) this.rustPackages.push(dir);
117
116
  const table = (name) => { const m = new RegExp(`^\\[${name}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm').exec(text); return m?.[1] ?? ''; };
@@ -10,6 +10,32 @@
10
10
  import readline from 'node:readline';
11
11
  import { TOOLS, LEGACY_TOOLS, SERVER_INSTRUCTIONS, callTool } from './tools.mjs';
12
12
 
13
+ const MAX_ITEMS = 100; // an array argument with no maxItems of its own
14
+ const MAX_STRING = 200_000; // a string argument (an issue pasted as a query is long, not this long)
15
+
16
+ /**
17
+ * The arguments as the tool's schema bounds them: numbers clamped to minimum/maximum, arrays cut
18
+ * to maxItems (else MAX_ITEMS), strings to MAX_STRING. A client is not trusted to respect the schema,
19
+ * and `depth: 1e9` or ten thousand queries would otherwise hold the server.
20
+ */
21
+ export function boundArgs(schema, raw) {
22
+ const args = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
23
+ const props = schema?.properties ?? {};
24
+ const str = (v) => (typeof v === 'string' && v.length > MAX_STRING ? v.slice(0, MAX_STRING) : v);
25
+ for (const [k, v] of Object.entries(args)) {
26
+ const p = props[k];
27
+ if (typeof v === 'number' && p && (p.type === 'integer' || p.type === 'number')) {
28
+ let n = Number.isFinite(v) ? v : (p.default ?? p.minimum ?? 0);
29
+ if (p.type === 'integer') n = Math.trunc(n);
30
+ if (p.minimum != null) n = Math.max(p.minimum, n);
31
+ if (p.maximum != null) n = Math.min(p.maximum, n);
32
+ args[k] = n;
33
+ } else if (Array.isArray(v)) args[k] = v.slice(0, p?.maxItems ?? MAX_ITEMS).map(str);
34
+ else args[k] = str(v);
35
+ }
36
+ return args;
37
+ }
38
+
13
39
  export const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
14
40
  const LATEST_HANDSHAKE = '2025-11-25';
15
41
  const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
@@ -105,7 +131,7 @@ export class McpServer {
105
131
  const name = params?.name;
106
132
  const tool = TOOLS.find(t => t.name === name) ?? LEGACY_TOOLS.find(t => t.name === name);
107
133
  if (!tool) { this.#error(id, -32602, `Unknown tool: ${name}`); return; }
108
- const args = params?.arguments ?? {};
134
+ const args = boundArgs(tool.inputSchema, params?.arguments);
109
135
  const missing = (tool.inputSchema.required ?? []).filter(k => args[k] === undefined || args[k] === null || args[k] === '');
110
136
  if (missing.length) {
111
137
  done({ content: [{ type: 'text', text: `Missing required argument(s): ${missing.join(', ')}.` }], isError: true });
package/src/mcp/tools.mjs CHANGED
@@ -412,7 +412,7 @@ async function toolText(intel, { pattern, path: p = null, literal = false, ignor
412
412
  if (sm.unbound) parts.push(`${sm.unbound} unbound`);
413
413
  if (sm.text) parts.push(`${sm.text} in comments/strings/other text`);
414
414
  if (sm.nonCode) parts.push(`${sm.nonCode} in non-code files`);
415
- out.push(`"${sm.ident}" across all ${r.total} matches: ${parts.join(', ')}.`);
415
+ out.push(`"${sm.ident}" across ${r.annotated < r.total ? `the first ${r.annotated} of` : 'all'} ${r.total} matches: ${parts.join(', ')}.`);
416
416
  }
417
417
  out.push(...definitionRescue(intel, pattern, literal, r));
418
418
  return out.join('\n');
@@ -14,6 +14,7 @@
14
14
  * out, as are HTML elements and the frameworks' own (`<Transition>`, `<svelte:self>`).
15
15
  */
16
16
  import { typescript } from './ecma.mjs';
17
+ import { escapeRegExp } from '../../util/regex.mjs';
17
18
 
18
19
  const BUILTIN = new Set(['Transition', 'TransitionGroup', 'KeepAlive', 'Teleport', 'Suspense', 'Component', 'Slot', 'Template', 'RouterView', 'RouterLink']);
19
20
 
@@ -65,7 +66,7 @@ function rewrite(source, kind) {
65
66
  ranges = ranges.filter(([a, b]) => source.slice(a, b).trim());
66
67
  }
67
68
  if (!ranges.length) return lines.join('\n');
68
- const known = (name) => !BUILTIN.has(name) && new RegExp(`\\b${name}\\b`).test(scriptText);
69
+ const known = (name) => !BUILTIN.has(name) && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(scriptText);
69
70
  const tags = [];
70
71
  for (const [a, b] of ranges) {
71
72
  const markup = source.slice(a, b).replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
@@ -115,7 +116,7 @@ const sfc = (id, extensions) => ({
115
116
  // its template's tags are that import, so the component itself takes another name
116
117
  const root = d.node.tree?.rootNode ?? d.node.parent;
117
118
  const imports = (root?.namedChildren ?? []).filter(c => c.type === 'import_statement').map(c => c.text).join('\n');
118
- return new RegExp(`\\b${comp}\\b`).test(imports) ? `${comp}Component` : comp;
119
+ return new RegExp(`\\b${escapeRegExp(comp)}\\b`).test(imports) ? `${comp}Component` : comp;
119
120
  },
120
121
  rewriteSource: (source) => rewrite(source, id),
121
122
  testFile: /(^|\/)(__tests__|tests?|spec|e2e)\//,
@@ -42,6 +42,7 @@ const ANNOTATION = /@[\w.]+(?:\s*\((?:[^()"]|"[^"]*"|\((?:[^()"]|"[^"]*")*\))*\)
42
42
 
43
43
  import fs from 'node:fs';
44
44
  import path from 'node:path';
45
+ import { readRepoFile } from '../util/paths.mjs';
45
46
 
46
47
  const BUILD_FILES = ['pom.xml', 'build.gradle', 'build.gradle.kts'];
47
48
 
@@ -53,8 +54,7 @@ const strip = (s) => String(s ?? '').trim().replace(/\s+/g, ' ');
53
54
  function moduleDeps(root, mods) {
54
55
  const info = new Map();
55
56
  for (const m of mods) {
56
- const dir = path.join(root, m);
57
- const pom = read(path.join(dir, 'pom.xml'));
57
+ const pom = read(root, m, 'pom.xml');
58
58
  if (pom != null) {
59
59
  let body = pom;
60
60
  for (let prev; prev !== body;) { prev = body; body = body.replace(/<!--[\s\S]*?-->/g, ''); }
@@ -63,7 +63,7 @@ function moduleDeps(root, mods) {
63
63
  info.set(m, { names: [own, path.posix.basename(m)].filter(Boolean), deps: ds });
64
64
  continue;
65
65
  }
66
- const gradle = read(path.join(dir, 'build.gradle')) ?? read(path.join(dir, 'build.gradle.kts'));
66
+ const gradle = read(root, m, 'build.gradle') ?? read(root, m, 'build.gradle.kts');
67
67
  if (gradle != null) info.set(m, { names: [path.posix.basename(m)], deps: [...gradle.matchAll(/project\(\s*(?:path\s*[:=]\s*)?['"]:?([\w:.-]+)['"]/g)].map(x => x[1].split(':').pop()) });
68
68
  }
69
69
  const byName = new Map();
@@ -73,7 +73,8 @@ function moduleDeps(root, mods) {
73
73
  return out;
74
74
  }
75
75
 
76
- function read(f) { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } }
76
+ /** A build file of module `m`: a small regular file inside the repository, or null. */
77
+ const read = (root, m, name) => readRepoFile(root, path.posix.join(m || '.', name));
77
78
 
78
79
  /** Spring's default bean name: `FooService` → `fooService`, but `URLMapper` stays `URLMapper`. */
79
80
  const beanName = (cls) => /^[A-Z]{2}/.test(cls) ? cls : cls[0].toLowerCase() + cls.slice(1);
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { collectSpring } from './injection-spring.mjs';
22
+ import { escapeRegExp } from '../util/regex.mjs';
22
23
 
23
24
  const PROVIDE = /\bprovide\s*:\s*([^,}\n]+?)\s*(?:,|}|$)/;
24
25
  const USE = /\buse(Class|Existing|Factory|Value)\s*:\s*([^,}\n]*)/;
@@ -42,7 +43,7 @@ function symRow(intel, id) {
42
43
  function stringValue(intel, s) {
43
44
  if (s.kind !== 'constant' && s.kind !== 'variable') return null;
44
45
  const l = intel.fileLines(s.path)?.[s.start_line - 1] ?? '';
45
- return new RegExp(`\\b${s.name}\\s*(?::[^=]+)?=\\s*(['"\`])([^'"\`]*)\\1\\s*;?\\s*$`).exec(l)?.[2] ?? null;
46
+ return new RegExp(`\\b${escapeRegExp(s.name)}\\s*(?::[^=]+)?=\\s*(['"\`])([^'"\`]*)\\1\\s*;?\\s*$`).exec(l)?.[2] ?? null;
46
47
  }
47
48
 
48
49
  /**
@@ -12,7 +12,7 @@ import { Indexer } from '../index/indexer.mjs';
12
12
  import { SearchEngine } from '../search/search.mjs';
13
13
  import { computeCentrality, loadGraph } from '../index/graph.mjs';
14
14
  import { confidenceLabel } from '../index/resolver.mjs';
15
- import { dataDir, withinRoot, isRevision } from '../util/paths.mjs';
15
+ import { dataDir, withinRoot, isRevision, DATA_DIR_NAME } from '../util/paths.mjs';
16
16
  import { isConstructor } from '../parse/languages.mjs';
17
17
  import { sigArity } from '../parse/arity.mjs';
18
18
  import { escapeRegExp } from '../util/regex.mjs';
@@ -27,6 +27,7 @@ const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials
27
27
  export class CodeIntel {
28
28
  constructor({ root, dbPath = null, log = () => {}, watch = false }) {
29
29
  this.root = path.resolve(root);
30
+ this.ownDb = dbPath == null;
30
31
  this.dbPath = dbPath ?? path.join(dataDir(this.root), 'index.db');
31
32
  this.log = log;
32
33
  this.watchEnabled = watch;
@@ -49,6 +50,13 @@ export class CodeIntel {
49
50
  * MCP server so the first tool call never times out on a large repository.
50
51
  */
51
52
  async open({ onProgress = null, background = false } = {}) {
53
+ // an index the repository ships (a crafted index.db, its -wal) is never opened: it was not
54
+ // built here and would be answered from as if it had been
55
+ if (this.ownDb) {
56
+ const r = gitSync(this.root, ['ls-files', '-z', '--', DATA_DIR_NAME]);
57
+ const tracked = r.status === 0 ? r.stdout.split('\0').filter(Boolean) : [];
58
+ if (tracked.length) throw new Error(`${DATA_DIR_NAME}/ is committed to this repository (${tracked.length} file${tracked.length === 1 ? '' : 's'}); graph-indexer does not use an index it did not build. Remove it from the repository (git rm -r --cached ${DATA_DIR_NAME}) and run again.`);
59
+ }
52
60
  this.store = new Store(this.dbPath);
53
61
  this.ix = new Indexer({ root: this.root, store: this.store, log: this.log });
54
62
  this.ix.load();
package/src/query/orm.mjs CHANGED
@@ -28,7 +28,8 @@
28
28
  */
29
29
  import fs from 'node:fs';
30
30
  import path from 'node:path';
31
- import { spawnSync } from 'node:child_process';
31
+ import { gitSync } from '../util/git.mjs';
32
+ import { readRepoFile } from '../util/paths.mjs';
32
33
 
33
34
  const PRISMA_OPS = 'findUnique|findUniqueOrThrow|findFirst|findFirstOrThrow|findMany|create|createMany|createManyAndReturn|update|updateMany|updateManyAndReturn|upsert|delete|deleteMany|count|aggregate|groupBy';
34
35
  const PRISMA_CALL = new RegExp(`\\b(prisma\\w*|\\w*Prisma|db|tx|trx|client|transaction)\\s*\\.\\s*([a-z]\\w*)\\s*\\.\\s*(${PRISMA_OPS})\\s*[(<]`, 'g');
@@ -45,7 +46,7 @@ const decorators = (s) => { try { return JSON.parse(s.decorators || '[]'); } cat
45
46
 
46
47
  /** Repository files matching a test, indexed or not (`*.prisma`, migration `.sql`): git's list, else a bounded walk. */
47
48
  function repoFiles(root, test) {
48
- const r = spawnSync('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8', timeout: 5000, maxBuffer: 64 << 20 });
49
+ const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { timeout: 5000, maxBuffer: 64 << 20 });
49
50
  if (r.status === 0 && r.stdout) return r.stdout.split('\0').filter(f => f && test(f));
50
51
  const out = [];
51
52
  const walk = (dir, depth) => {
@@ -65,7 +66,8 @@ function repoFiles(root, test) {
65
66
  function readLines(intel, rel) {
66
67
  const l = intel.fileLines(rel);
67
68
  if (l) return l;
68
- try { return fs.readFileSync(path.join(intel.root, rel), 'utf8').split(/\r?\n/); } catch { return null; }
69
+ // not indexed (a .prisma file, a migration): still only a regular file inside the repository
70
+ return readRepoFile(intel.root, rel)?.split(/\r?\n/) ?? null;
69
71
  }
70
72
 
71
73
  /** Prisma models of every `.prisma` file. */
@@ -15,12 +15,14 @@ const MAX_FILE_BYTES = 1_500_000;
15
15
  // a caller's pattern gets this much matching time per search (reading files does not count): one
16
16
  // with catastrophic backtracking (`(a+)+$`) is stopped instead of blocking the server
17
17
  const REGEX_BUDGET_MS = 5000;
18
+ const MAX_KEPT = 50_000;
18
19
  const SCAN = new vm.Script(`(() => {
19
20
  if (!re.test(text)) return null;
20
21
  const lines = text.split(/\\r?\\n/), hits = [];
21
22
  for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
22
23
  return { lines, hits };
23
24
  })()`);
25
+ const GLOB_FILTER = new vm.Script(`paths.filter(p => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p))`);
24
26
  const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
25
27
  const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
26
28
  const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
@@ -54,7 +56,7 @@ function globSource(glob) {
54
56
  i++;
55
57
  if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
56
58
  } else if (c === '?') re += '[^/]';
57
- else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += glob.slice(i, j + 1).replace(/^\[!/, '[^'); i = j; }
59
+ else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += '[' + glob.slice(i + 1, j).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'; i = j; }
58
60
  else if (c === '{' && glob.indexOf('}', i) > i) { const j = glob.indexOf('}', i); re += `(?:${glob.slice(i + 1, j).split(',').map(escapeRe).join('|')})`; i = j; }
59
61
  else re += escapeRe(c);
60
62
  }
@@ -71,7 +73,16 @@ export function findFiles(root, pattern, { path: prefix = null } = {}) {
71
73
  if (/[*?[{]/.test(pattern)) {
72
74
  const onName = !pattern.includes('/');
73
75
  const re = new RegExp(onName ? `^${globSource(pattern)}$` : `(^|/)${globSource(pattern.replace(/^\.?\//, ''))}$`);
74
- test = (p) => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p);
76
+ // a glob of many stars (`*a*a*a*a*a*a*b`) backtracks for minutes on one long name: matched
77
+ // under the same budget as search_text's expressions, so it cannot block the server
78
+ const paths = listFiles(root).filter(p => !pre || p === pre || p.startsWith(pre + '/'));
79
+ const ctx = vm.createContext({ re, paths, onName });
80
+ try {
81
+ return GLOB_FILTER.runInContext(ctx, { timeout: REGEX_BUDGET_MS }).sort();
82
+ } catch (e) {
83
+ if (e.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw e;
84
+ throw new Error(`The glob took more than ${REGEX_BUDGET_MS / 1000} s to match (too many wildcards?). Use fewer wildcards.`);
85
+ }
75
86
  } else {
76
87
  const t = pattern.toLowerCase();
77
88
  test = (p) => p.toLowerCase().includes(t);
@@ -133,7 +144,8 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
133
144
  const f = indexed.get(rel);
134
145
  for (const i of scan.hits) {
135
146
  total++;
136
- matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
147
+ // counted, but kept (and annotated) only up to a bound: memory and time stay bounded
148
+ if (matches.length < MAX_KEPT) matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
137
149
  }
138
150
  }
139
151
  const ident = identifierOf(pattern, literal);
@@ -149,13 +161,22 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
149
161
  WHERE r.file_id = ? AND r.name = ?`, f.id, ident) : [];
150
162
  const refsAt = new Map();
151
163
  for (const r of refs) (refsAt.get(r.line) ?? refsAt.set(r.line, []).get(r.line)).push(r);
164
+ // innermost definition around each matched line, ignoring ones that start on it (inline
165
+ // classes, the def itself): one sweep over the definitions in start order, writing only the
166
+ // matched lines, instead of every definition for every match
167
+ const wanted = new Map(ms.map(m => [m.line, null]));
168
+ const lines = [...wanted.keys()].sort((a, b) => a - b);
169
+ for (const s of syms) {
170
+ let lo = 0, hi = lines.length;
171
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (lines[mid] <= s.start_line) lo = mid + 1; else hi = mid; }
172
+ for (let k = lo; k < lines.length && lines[k] <= s.end_line; k++) wanted.set(lines[k], s);
173
+ }
174
+ const defAt = new Map();
175
+ if (summary) for (const s of syms) if (s.name === ident && !defAt.has(s.name_line)) defAt.set(s.name_line, s);
152
176
  for (const m of ms) {
153
- // innermost definition around the line, ignoring ones that start on it (inline classes, the def itself)
154
- let encl = null;
155
- for (const s of syms) if (s.start_line < m.line && s.end_line >= m.line && (!encl || s.start_line >= encl.start_line)) encl = s;
156
- m.encl = encl?.qname ?? null;
177
+ m.encl = wanted.get(m.line)?.qname ?? null;
157
178
  if (!summary) continue;
158
- const def = syms.find(s => s.name === ident && s.name_line === m.line);
179
+ const def = defAt.get(m.line);
159
180
  const rs = refsAt.get(m.line) ?? [];
160
181
  if (def) { m.what = 'def'; m.target = def.qname; summary.defs++; continue; }
161
182
  const bound = rs.filter(r => r.dst_id != null);
@@ -174,5 +195,5 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
174
195
  }
175
196
  }
176
197
  matches.sort((a, b) => rank(a.path, a.isTest) + (a.code ? 0 : 1) - (rank(b.path, b.isTest) + (b.code ? 0 : 1)) || a.path.localeCompare(b.path) || a.line - b.line);
177
- return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: matches.length > limit };
198
+ return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: total > limit, annotated: matches.length };
178
199
  }
package/src/store/db.mjs CHANGED
@@ -128,11 +128,21 @@ CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts5(
128
128
  `;
129
129
 
130
130
  export class Store {
131
+ /**
132
+ * An index.db the repository shipped is not trusted: defensive mode refuses the statements that
133
+ * can corrupt the file (writable_schema, …) and an untrusted schema may not call functions.
134
+ */
135
+ #harden() {
136
+ try { this.db.enableDefensive?.(true); } catch { /* older Node */ }
137
+ this.db.exec('PRAGMA trusted_schema = OFF;');
138
+ }
139
+
131
140
  constructor(dbPath) {
132
141
  this.path = dbPath;
133
142
  this.stmts = new Map();
134
143
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
135
144
  this.db = new DatabaseSync(dbPath);
145
+ this.#harden();
136
146
  this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY; PRAGMA cache_size = -65536; PRAGMA busy_timeout = 5000;');
137
147
  const v = this.#schemaVersion();
138
148
  if (v !== null && v !== SCHEMA_VERSION) {
@@ -140,6 +150,7 @@ export class Store {
140
150
  this.db.close();
141
151
  for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch { /* none */ } }
142
152
  this.db = new DatabaseSync(dbPath);
153
+ this.#harden();
143
154
  this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY; PRAGMA cache_size = -65536; PRAGMA busy_timeout = 5000;');
144
155
  }
145
156
  this.db.exec(SCHEMA);
package/src/util/git.mjs CHANGED
@@ -1,30 +1,79 @@
1
1
  /**
2
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.
3
+ * fsmonitor, no hooks, no signature verification, no external diff or textconv, every
4
+ * clean/smudge/process filter switched off, and no descent into submodules (whose own config the
5
+ * overrides below do not cover). A repository copied with its .git directory (an archive, a shared
6
+ * folder) is not trusted. Every git call graph-indexer makes goes through gitSync.
6
7
  */
7
8
  import { spawnSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
8
11
 
9
12
  const COMMAND_KEY = /^(filter\.[^\n]+)\.(clean|smudge|process)$|^diff\.[^\n]+\.(textconv|command)$/;
10
13
 
14
+ let gitBin = null;
15
+ /**
16
+ * The git executable. On Windows a bare `git` is looked up in the working directory before PATH,
17
+ * so a `git.exe` committed at the repository root would run instead: look it up on PATH only.
18
+ */
19
+ export function gitExecutable() {
20
+ if (gitBin) return gitBin;
21
+ gitBin = 'git';
22
+ if (process.platform === 'win32') {
23
+ const exts = (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean);
24
+ for (const dir of (process.env.PATH || process.env.Path || '').split(path.delimiter)) {
25
+ if (!dir || !path.isAbsolute(dir)) continue;
26
+ const hit = exts.map(e => path.join(dir, 'git' + e.toLowerCase())).find(f => { try { return fs.statSync(f).isFile(); } catch { return false; } });
27
+ if (hit) { gitBin = hit; break; }
28
+ }
29
+ }
30
+ return gitBin;
31
+ }
32
+
33
+ let envConfig = null;
34
+ /** git ≥ 2.31 takes config from GIT_CONFIG_COUNT/KEY/VALUE, which carry a key verbatim. */
35
+ function supportsEnvConfig() {
36
+ if (envConfig != null) return envConfig;
37
+ const m = /(\d+)\.(\d+)/.exec(spawnSync(gitExecutable(), ['--version'], { encoding: 'utf8' }).stdout ?? '');
38
+ return (envConfig = !!m && (+m[1] > 2 || (+m[1] === 2 && +m[2] >= 31)));
39
+ }
40
+
41
+ /**
42
+ * The overrides for `root`: fixed ones, plus one blanking each command its config names. Those go
43
+ * in the environment, not as `-c key=`: git splits a `-c` at the first `=`, and a driver may be
44
+ * named `x=y`, so `-c filter.x=y.clean=` would set `filter.x` and leave the real command on.
45
+ * Returns null when the overrides cannot be expressed (an old git and such a name): git is not run.
46
+ */
11
47
  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')) {
48
+ const pairs = [['core.fsmonitor', 'false'], ['core.hooksPath', '/dev/null'], ['diff.external', ''], ['log.showSignature', 'false']];
49
+ const r = spawnSync(gitExecutable(), ['config', '-z', '--name-only', '--get-regexp', '^(filter|diff)\\.'], { cwd: root, encoding: 'utf8' });
50
+ for (const key of (r.status === 0 ? r.stdout : '').split('\0')) {
15
51
  const m = COMMAND_KEY.exec(key);
16
52
  if (!m) continue;
17
- args.push('-c', `${key}=`);
53
+ pairs.push([key, '']);
18
54
  // 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`);
55
+ if (m[1]) pairs.push([`${m[1]}.required`, 'false']);
56
+ }
57
+ if (supportsEnvConfig()) {
58
+ const base = Number(process.env.GIT_CONFIG_COUNT) || 0;
59
+ const env = { GIT_CONFIG_COUNT: String(base + pairs.length) };
60
+ pairs.forEach(([k, v], i) => { env[`GIT_CONFIG_KEY_${base + i}`] = k; env[`GIT_CONFIG_VALUE_${base + i}`] = v; });
61
+ return { args: [], env };
20
62
  }
21
- return args;
63
+ if (pairs.some(([k]) => k.includes('='))) return null;
64
+ return { args: pairs.flatMap(([k, v]) => ['-c', `${k}=${v}`]), env: {} };
22
65
  }
23
66
 
24
- const NO_TOOLS = { diff: ['--no-ext-diff', '--no-textconv'], log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'] };
67
+ const NO_TOOLS = {
68
+ diff: ['--no-ext-diff', '--no-textconv', '--ignore-submodules=all'],
69
+ log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'],
70
+ status: ['--ignore-submodules=all'],
71
+ };
25
72
 
26
73
  /** spawnSync('git', args) in `root`, hardened as above; diff and log never run external tools. */
27
74
  export function gitSync(root, args, opts = {}) {
28
75
  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 });
76
+ const h = hardening(root);
77
+ if (!h) return { status: 1, stdout: '', stderr: 'git not run: the repository config names a filter or diff driver this git cannot switch off', error: null };
78
+ return spawnSync(gitExecutable(), [...h.args, ...sub], { cwd: root, encoding: 'utf8', ...opts, env: { ...process.env, ...opts.env, ...h.env } });
30
79
  }
@@ -12,6 +12,11 @@ export function dataDir(root) {
12
12
  const st = fs.lstatSync(dir);
13
13
  if (!st.isDirectory()) throw new Error(`${dir} is not a directory (a symbolic link?); remove it and run again`);
14
14
  if ((st.mode & 0o077) !== 0) { try { fs.chmodSync(dir, 0o700); } catch { /* read-only */ } }
15
+ // nor may a link inside it (`index.db-wal` → ~/.zshrc, `resident.started` → ~/.ssh/…) send the
16
+ // index's writes elsewhere: everything in here is ours, so a link is never legitimate
17
+ try {
18
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) if (e.isSymbolicLink()) fs.unlinkSync(path.join(dir, e.name));
19
+ } catch { /* read-only */ }
15
20
  const gi = path.join(dir, '.gitignore');
16
21
  if (!fs.existsSync(gi)) { try { fs.writeFileSync(gi, '*\n'); } catch { /* read-only */ } }
17
22
  return dir;
@@ -37,11 +42,13 @@ const realRoots = new Map();
37
42
  */
38
43
  export function withinRoot(root, rel) {
39
44
  let realRoot = realRoots.get(root);
40
- if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
45
+ if (!realRoot) { try { realRoot = fs.realpathSync.native(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
41
46
  let real;
42
- try { real = fs.realpathSync(path.join(root, rel)); } catch { return false; }
47
+ // the native realpath gives the name as stored on disk, so on a case-insensitive file system
48
+ // (macOS, Windows) `.GIT/config` resolves to `.git/config` and is refused like it
49
+ try { real = fs.realpathSync.native(path.join(root, rel)); } catch { return false; }
43
50
  if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return false;
44
- const top = path.relative(realRoot, real).split(path.sep)[0];
51
+ const top = path.relative(realRoot, real).split(path.sep)[0].toLowerCase();
45
52
  return top !== '.git' && top !== DATA_DIR_NAME;
46
53
  }
47
54