sentinel-ir-mcp 0.4.0 → 0.4.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/README.md CHANGED
@@ -25,6 +25,32 @@ of safety. Other languages are not supported yet. What is detected in Python
25
25
  (and what is not) is listed in the
26
26
  [sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
27
27
 
28
+ **0.4.1** uses `sentinel-ir` 0.4.3:
29
+
30
+ - **Faster on large JavaScript:** a valid 1 MiB file takes about 5 s instead
31
+ of about 170 s, so `analyze_directory` no longer runs past the 60 s request
32
+ timeout of MCP SDK clients on one big file.
33
+ - **Read-time check:** files are opened first and then checked, so a file
34
+ swapped for a symlink between listing and reading is refused instead of read
35
+ from outside the allowed folders. A file removed during a scan no longer
36
+ fails the whole scan; it is listed in `skipped.unreadable`. Files with more
37
+ than one hard link are refused (`skipped.hardlinks`).
38
+ - **Finite limits:** `--max-file-bytes` is capped at 2 MiB. Valid JavaScript
39
+ with `sentinel-ir` 0.4.3:
40
+
41
+ | size | Linux | Windows CI (Node 20/22/24) |
42
+ |---|---|---|
43
+ | 1 MiB | 4–5 s, 0.9 GB | 13–23 s, 0.8 GB |
44
+ | 2 MiB | 10–14 s, 1.6 GB | 29–40 s, 1.5 GB |
45
+ | 4 MiB | 25–29 s, 3 GB | process aborts |
46
+
47
+ A file above 2 MiB can take longer than the 60 s request timeout on its own.
48
+ - **Time budget:** `analyze_directory` starts no new file after 30 s
49
+ (`--time-budget-ms`). Then `timedOut` is `true` and the remaining files are
50
+ listed in `notAnalyzed`. A file already being analyzed is not interrupted.
51
+ - **Parser errors:** incomplete files carry `facts.parseError` with the line,
52
+ column and kind of the first parser error (from `sentinel-ir` 0.4.3).
53
+
28
54
  **0.4.0** uses `sentinel-ir` 0.4.2:
29
55
 
30
56
  - **Native code:** `ctypes` / `cffi`, `process.dlopen`, `*.node` addons and
@@ -231,9 +257,10 @@ if the client offers them; if it offers none, only `analyze_code` works.
231
257
  ### Options
232
258
 
233
259
  ```text
234
- sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [folder ...]
260
+ sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
235
261
 
236
- --max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB)
262
+ --max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB, max 2097152 = 2 MiB)
263
+ --time-budget-ms <n> analyze_directory starts no new file after this (default 30000, max 600000)
237
264
  --max-files <n> default file limit for analyze_directory (default 50, max 500)
238
265
  ```
239
266
 
@@ -248,7 +275,15 @@ sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [folder ...]
248
275
  or, without folder arguments, inside one of the client's MCP roots. `../`,
249
276
  absolute paths elsewhere and symlinks pointing outside are refused.
250
277
  - **`analyze_directory`** skips `node_modules`, `.git`, hidden folders and
251
- every symlink, and stops after `max_files` files (`truncated: true`).
278
+ every symlink, and stops after `max_files` files (`truncated: true`) or
279
+ when the time budget is spent (`timedOut: true`, rest in `notAnalyzed`).
280
+ Each file is checked again when it is opened: it must still be a regular
281
+ file inside the allowed folders.
282
+ - **Hard links are refused.** A hard link inside the folder can point to a
283
+ file outside it, and the other names of a file cannot be found. So files
284
+ with more than one hard link are refused (`analyze_file` error,
285
+ `skipped.hardlinks` in `analyze_directory`). This also refuses harmless hard
286
+ links, e.g. pnpm's in `node_modules`.
252
287
  - **The IR is still untrusted text.** Comments, string bodies and function
253
288
  bodies are not copied into it, so instructions hidden there do not reach the
254
289
  agent through the IR. It does contain identifiers and some literals from the
@@ -23,7 +23,8 @@ Usage: sentinel-ir-mcp [options] [folder ...]
23
23
  command must be written as ./setup, ./doctor or ./guide. Without folders the
24
24
  server uses the MCP roots offered by the client; if the
25
25
  client offers none, only analyze_code works.
26
- --max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes})
26
+ --max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes}, max ${DEFAULT_LIMITS.hardMaxFileBytes})
27
+ --time-budget-ms <n> analyze_directory starts no new file after this time (default ${DEFAULT_LIMITS.timeBudgetMs}, max ${DEFAULT_LIMITS.hardMaxTimeBudgetMs})
27
28
  --max-files <n> Default file limit for analyze_directory (default ${DEFAULT_LIMITS.maxFiles}, max ${DEFAULT_LIMITS.hardMaxFiles})
28
29
  -h, --help Show this help
29
30
  -v, --version Show the version
@@ -36,6 +37,12 @@ function die(message) {
36
37
  process.exit(2);
37
38
  }
38
39
 
40
+ const NUMBER_OPTIONS = {
41
+ "--max-file-bytes": ["maxFileBytes", DEFAULT_LIMITS.hardMaxFileBytes],
42
+ "--max-files": ["maxFiles", DEFAULT_LIMITS.hardMaxFiles],
43
+ "--time-budget-ms": ["timeBudgetMs", DEFAULT_LIMITS.hardMaxTimeBudgetMs]
44
+ };
45
+
39
46
  function parseArgs(argv) {
40
47
  const opts = { roots: [] };
41
48
  for (let i = 0; i < argv.length; i++) {
@@ -44,12 +51,12 @@ function parseArgs(argv) {
44
51
  if (arg.startsWith("--") && arg.includes("=")) [arg, value] = [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)];
45
52
  if (arg === "-h" || arg === "--help") { process.stdout.write(USAGE + "\n"); process.exit(0); }
46
53
  if (arg === "-v" || arg === "--version") { process.stdout.write(version + "\n"); process.exit(0); }
47
- if (arg === "--max-file-bytes" || arg === "--max-files") {
54
+ if (Object.hasOwn(NUMBER_OPTIONS, arg)) {
55
+ const [key, max] = NUMBER_OPTIONS[arg];
48
56
  if (value === undefined) value = argv[++i];
49
57
  const n = Number(value);
50
- const max = arg === "--max-files" ? DEFAULT_LIMITS.hardMaxFiles : Number.MAX_SAFE_INTEGER;
51
58
  if (!Number.isInteger(n) || n < 1 || n > max) die(`${arg} must be an integer between 1 and ${max}`);
52
- opts[arg === "--max-files" ? "maxFiles" : "maxFileBytes"] = n;
59
+ opts[key] = n;
53
60
  } else if (arg.startsWith("-")) {
54
61
  die(`unknown option ${arg}`);
55
62
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinel-ir-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript and Python files instead of the raw source. Read-only, sandboxed to allowed folders.",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/sdk": "^1.32.1",
22
- "sentinel-ir": "^0.4.2",
22
+ "sentinel-ir": "^0.4.3",
23
23
  "zod": "^4.1.0"
24
24
  },
25
25
  "keywords": [
package/src/analyze.js CHANGED
@@ -1,9 +1,64 @@
1
1
  "use strict";
2
2
 
3
3
  const fs = require("fs/promises");
4
+ const { constants, realpathSync } = require("fs");
4
5
  const path = require("path");
5
6
  const { analyzeAsync, compress } = require("sentinel-ir");
6
- const { AccessError, resolveInside, isSourceFile } = require("./paths");
7
+ const { AccessError, resolveInside, isInside, isSourceFile } = require("./paths");
8
+
9
+ // O_NONBLOCK: opening a FIFO swapped in after listing must not block the
10
+ // server forever. Neither flag exists on Windows; the inode check covers that.
11
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
12
+
13
+ class TooLargeError extends AccessError {}
14
+ class HardlinkError extends AccessError {}
15
+
16
+ // Reads the file that is actually opened, not the one checked earlier: the
17
+ // path may be swapped for a symlink between the check and the read. The open
18
+ // handle must be a regular file whose inode is the one now found at a real
19
+ // path inside the allowed folders, and at most maxFileBytes are read.
20
+ async function readInside(full, roots, maxFileBytes, label) {
21
+ let handle;
22
+ try {
23
+ handle = await fs.open(full, OPEN_FLAGS);
24
+ } catch (err) {
25
+ if (err && err.code === "ELOOP") throw new AccessError(`Access denied: ${label} is a symlink`);
26
+ throw err;
27
+ }
28
+ try {
29
+ const opened = await handle.stat({ bigint: true });
30
+ if (!opened.isFile()) throw new AccessError(`Not a file: ${label}`);
31
+ if (opened.size > BigInt(maxFileBytes)) {
32
+ throw new TooLargeError(`File too large: ${label} is ${opened.size} bytes, limit is ${maxFileBytes}`);
33
+ }
34
+ // Same realpath as resolveInside: on Windows fs.promises.realpath
35
+ // expands 8.3 short names (GITLAB~1) and would never match the roots.
36
+ const real = realpathSync(full);
37
+ const current = await fs.stat(real, { bigint: true });
38
+ if (!roots.some((r) => isInside(real, r)) || current.dev !== opened.dev || current.ino !== opened.ino) {
39
+ throw new AccessError(`Access denied: ${label} changed while it was being read`);
40
+ }
41
+ // Another name of the same file may lie outside the allowed folders,
42
+ // and where it lies cannot be found out, so such files are refused.
43
+ // Both counts: the handle's alone can read 1 while the path is swapped.
44
+ const links = opened.nlink > current.nlink ? opened.nlink : current.nlink;
45
+ if (links > 1n) {
46
+ throw new HardlinkError(`Access denied: ${label} has ${links} hard links; files with more than one are refused`);
47
+ }
48
+ const buffer = Buffer.alloc(Number(opened.size) + 1);
49
+ let length = 0;
50
+ while (length < buffer.length) {
51
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
52
+ if (!bytesRead) break;
53
+ length += bytesRead;
54
+ }
55
+ if (length > maxFileBytes) throw new TooLargeError(`File too large: ${label} grew past the limit of ${maxFileBytes} bytes`);
56
+ if (length === buffer.length) throw new AccessError(`Access denied: ${label} changed while it was being read`);
57
+ return buffer.toString("utf8", 0, length);
58
+ } finally {
59
+ await handle.close();
60
+ }
61
+ }
7
62
 
8
63
  const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", "venv", "site-packages"]);
9
64
  const RISK_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -26,7 +81,7 @@ async function analyzeFile(requested, roots, { maxFileBytes }) {
26
81
  if (stat.size > maxFileBytes) {
27
82
  throw new AccessError(`File too large: ${requested} is ${stat.size} bytes, limit is ${maxFileBytes}`);
28
83
  }
29
- const code = await fs.readFile(real, "utf8");
84
+ const code = await readInside(real, roots, maxFileBytes, requested);
30
85
  return analyzeCode(code, display);
31
86
  }
32
87
 
@@ -34,12 +89,20 @@ async function analyzeFile(requested, roots, { maxFileBytes }) {
34
89
  // __pycache__, venv, site-packages and hidden folders are skipped.
35
90
  async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
36
91
  const files = [];
37
- const skipped = { tooLarge: [], symlinks: 0, unreadable: [] };
92
+ const skipped = { tooLarge: [], symlinks: 0, unreadable: [], hardlinks: [] };
38
93
  let truncated = false;
39
94
  const queue = [dir];
40
95
  while (queue.length && !truncated) {
41
96
  const current = queue.shift();
42
- const entries = (await fs.readdir(current, { withFileTypes: true }))
97
+ let entries;
98
+ try {
99
+ entries = await fs.readdir(current, { withFileTypes: true });
100
+ } catch {
101
+ // Removed or made unreadable while the scan runs.
102
+ skipped.unreadable.push(path.relative(root, current).split(path.sep).join("/") || ".");
103
+ continue;
104
+ }
105
+ entries = entries
43
106
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
44
107
  for (const entry of entries) {
45
108
  const full = path.join(current, entry.name);
@@ -49,7 +112,13 @@ async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
49
112
  } else if (entry.isDirectory()) {
50
113
  if (recursive && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) queue.push(full);
51
114
  } else if (entry.isFile() && isSourceFile(entry.name)) {
52
- const { size } = await fs.stat(full);
115
+ let size;
116
+ try {
117
+ ({ size } = await fs.lstat(full));
118
+ } catch {
119
+ skipped.unreadable.push(rel);
120
+ continue;
121
+ }
53
122
  if (size > maxFileBytes) {
54
123
  skipped.tooLarge.push(rel);
55
124
  } else if (files.length >= maxFiles) {
@@ -64,20 +133,30 @@ async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
64
133
  return { files, skipped, truncated };
65
134
  }
66
135
 
67
- async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recursive = true }) {
136
+ async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity }) {
137
+ const started = Date.now();
68
138
  const { real, root, display } = resolveInside(requested, roots);
69
139
  const stat = await fs.stat(real);
70
140
  if (!stat.isDirectory()) throw new AccessError(`Not a folder: ${requested}`);
71
141
 
72
142
  const { files, skipped, truncated } = await collectFiles(real, root, { maxFiles, maxFileBytes, recursive });
73
143
  const results = [];
144
+ const notAnalyzed = [];
74
145
  for (const { full, rel } of files) {
146
+ // A running analysis cannot be interrupted, so the budget only stops
147
+ // new files from starting; the rest are listed in notAnalyzed.
148
+ if (notAnalyzed.length || Date.now() - started >= timeBudgetMs) {
149
+ notAnalyzed.push(rel);
150
+ continue;
151
+ }
75
152
  // One file that cannot be read or analyzed must not hide the others.
76
153
  let ir;
77
154
  try {
78
- ir = await analyzeCode(await fs.readFile(full, "utf8"), rel);
79
- } catch {
80
- skipped.unreadable.push(rel);
155
+ ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel), rel);
156
+ } catch (err) {
157
+ const list = err instanceof TooLargeError ? skipped.tooLarge
158
+ : err instanceof HardlinkError ? skipped.hardlinks : skipped.unreadable;
159
+ list.push(rel);
81
160
  continue;
82
161
  }
83
162
  results.push({
@@ -107,6 +186,8 @@ async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recu
107
186
  analyzed: results.length,
108
187
  truncated,
109
188
  maxFiles,
189
+ timedOut: notAnalyzed.length > 0,
190
+ notAnalyzed,
110
191
  skipped,
111
192
  riskCounts,
112
193
  statusCounts,
@@ -115,4 +196,4 @@ async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recu
115
196
  };
116
197
  }
117
198
 
118
- module.exports = { analyzeCode, analyzeFile, analyzeDirectory, SKIP_DIRS };
199
+ module.exports = { analyzeCode, analyzeFile, analyzeDirectory, readInside, SKIP_DIRS };
package/src/doctor.js CHANGED
@@ -10,7 +10,7 @@ const { readConfig } = require("./setup");
10
10
 
11
11
  const BIN = path.join(__dirname, "..", "bin", "sentinel-ir-mcp.js");
12
12
  const EXPECTED_TOOLS = ["analyze_code", "analyze_directory", "analyze_file"];
13
- const VALUE_OPTIONS = new Set(["--max-file-bytes", "--max-files"]);
13
+ const VALUE_OPTIONS = new Set(["--max-file-bytes", "--max-files", "--time-budget-ms"]);
14
14
 
15
15
  function irVersion() {
16
16
  try {
package/src/guide.js CHANGED
@@ -8,9 +8,12 @@ env reads and network/filesystem/process/crypto calls. It reads files only.
8
8
 
9
9
  Recommended order:
10
10
  1. Call analyze_directory on the project (or a sub-folder) first.
11
- 2. Look at "incomplete" and "skipped.unreadable" before anything else. Those
11
+ 2. Look at "incomplete", "skipped.unreadable" and "skipped.hardlinks" before
12
+ anything else. Those
12
13
  files were not fully analyzed: a low risk there is NOT evidence of safety.
13
- Read their raw source if they matter.
14
+ Read their raw source if they matter. If "timedOut" is true, the files in
15
+ "notAnalyzed" were not analyzed at all: call analyze_directory on a
16
+ sub-folder or analyze_file for them.
14
17
  3. Pick the relevant files from "files" (sorted from highest to lowest risk).
15
18
  4. Call analyze_file for the full IR of a single file.
16
19
  5. Read raw source only for the files you actually need to change or verify.
package/src/server.js CHANGED
@@ -8,6 +8,12 @@ const { analyzeCode, analyzeFile, analyzeDirectory } = require("./analyze");
8
8
 
9
9
  const DEFAULT_LIMITS = Object.freeze({
10
10
  maxFileBytes: 1024 * 1024,
11
+ // 4 MiB of JavaScript aborts the process on Windows CI (3 GB on Linux).
12
+ hardMaxFileBytes: 2 * 1024 * 1024,
13
+ // Plus one 1 MiB file (up to 23 s on Windows CI) stays below the 60 s
14
+ // request timeout of MCP SDK clients.
15
+ timeBudgetMs: 30000,
16
+ hardMaxTimeBudgetMs: 600000,
11
17
  maxFiles: 50,
12
18
  hardMaxFiles: 500
13
19
  });
@@ -30,7 +36,7 @@ const INSTRUCTIONS =
30
36
  "or risk, then read the raw source of the files that matter. Supported: JavaScript (.js, .mjs, .cjs), TypeScript (.ts, .mts, .cts, .tsx) and Python (.py, .pyi); the language is picked from the file extension. " +
31
37
  "Rely on analysisStatus, world.risk, forbidden and facts; analysisStatus \"incomplete\" means the file did not parse cleanly. " +
32
38
  "analyze_directory lists such files separately in 'incomplete'; their low risk is not evidence of safety. " +
33
- "Suggested order: analyze_directory first, check 'incomplete' and 'skipped.unreadable', pick the relevant files, " +
39
+ "Suggested order: analyze_directory first, check 'incomplete', 'skipped.unreadable' and 'notAnalyzed' (set when the time budget ran out), pick the relevant files, " +
34
40
  "then analyze_file, and read raw source only where needed. " +
35
41
  "Read-only: nothing is written, executed or sent over the network. " + UNTRUSTED_NOTE;
36
42
 
@@ -56,8 +62,9 @@ async function guarded(fn) {
56
62
 
57
63
  function createServer(options = {}) {
58
64
  const limits = {
59
- maxFileBytes: options.maxFileBytes ?? DEFAULT_LIMITS.maxFileBytes,
60
- maxFiles: options.maxFiles ?? DEFAULT_LIMITS.maxFiles
65
+ maxFileBytes: Math.min(options.maxFileBytes ?? DEFAULT_LIMITS.maxFileBytes, DEFAULT_LIMITS.hardMaxFileBytes),
66
+ maxFiles: options.maxFiles ?? DEFAULT_LIMITS.maxFiles,
67
+ timeBudgetMs: Math.min(options.timeBudgetMs ?? DEFAULT_LIMITS.timeBudgetMs, DEFAULT_LIMITS.hardMaxTimeBudgetMs)
61
68
  };
62
69
  const fixedRoots = canonicalRoots(options.roots || []);
63
70
 
@@ -112,6 +119,9 @@ function createServer(options = {}) {
112
119
  "you when there were more. 'incomplete' lists files whose analysis did not finish (e.g. parse errors); " +
113
120
  "their risk is not reliable, check them separately. statusCounts counts complete vs incomplete. " +
114
121
  "Files that could not be read are listed in skipped.unreadable and are not counted as analyzed. " +
122
+ "Files with more than one hard link are refused and listed in skipped.hardlinks. " +
123
+ `No new file is started after ${limits.timeBudgetMs / 1000} s: then timedOut is true and the remaining files are in notAnalyzed; ` +
124
+ "call again on a subfolder or use analyze_file for them. " +
115
125
  "Use analyze_file for the full IR of a single file. " + UNTRUSTED_NOTE,
116
126
  inputSchema: {
117
127
  path: z.string().min(1).optional().describe("Folder path, absolute or relative to the first allowed folder (default: the first allowed folder)"),
@@ -124,6 +134,7 @@ function createServer(options = {}) {
124
134
  analyzeDirectory(path ?? ".", await allowedRoots(), {
125
135
  maxFiles: max_files ?? limits.maxFiles,
126
136
  maxFileBytes: limits.maxFileBytes,
137
+ timeBudgetMs: limits.timeBudgetMs,
127
138
  recursive: recursive ?? true
128
139
  })));
129
140