sentinel-ir-mcp 0.4.2 → 0.4.4
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 +123 -7
- package/bin/sentinel-ir-mcp.js +38 -7
- package/package.json +2 -2
- package/src/analyze.js +45 -12
- package/src/auditLog.js +204 -0
- package/src/cli.js +39 -6
- package/src/clients.js +52 -13
- package/src/doctor.js +78 -9
- package/src/exclude.js +131 -0
- package/src/excludeReport.js +22 -0
- package/src/guide.js +2 -0
- package/src/logView.js +149 -0
- package/src/server.js +50 -12
- package/src/setup.js +86 -5
package/README.md
CHANGED
|
@@ -25,6 +25,39 @@ 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.4** can keep a log of what the agent asked for (off unless you turn it on):
|
|
29
|
+
|
|
30
|
+
- **One question in `setup`:** *"Keep a log of what the agent asks for (file
|
|
31
|
+
names and results, never your code)? [y/N]"*. Or `setup --audit-log`;
|
|
32
|
+
`--no-audit-log` turns it off. Running `setup` again keeps the setting.
|
|
33
|
+
- **One command to read it:** `npx -y sentinel-ir-mcp@0.4 log` prints the last
|
|
34
|
+
50 entries as plain lines, `--all` everything, `--json` for scripts:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
2026-10-18 10:32:05 [cursor] file src/app.js: critical
|
|
38
|
+
2026-10-18 10:32:09 [cursor] REFUSED file secrets/aws.js: excluded by your settings
|
|
39
|
+
2026-10-18 10:33:40 [cursor] folder .: 42 file(s) (1 critical, 6 medium, 35 low), 3 excluded
|
|
40
|
+
critical src/app.js
|
|
41
|
+
```
|
|
42
|
+
- `doctor` says whether the log is on, where it is and how big, and proves the
|
|
43
|
+
log can be written. Details: [Audit log](#audit-log).
|
|
44
|
+
|
|
45
|
+
Also uses `sentinel-ir` 0.4.5 (Python parse errors point where Python does).
|
|
46
|
+
|
|
47
|
+
**0.4.3** lets you exclude files and folders:
|
|
48
|
+
|
|
49
|
+
- **One question in `setup`:** *"Files or folders the agent must not read"*,
|
|
50
|
+
e.g. `secrets, dist, *.min.js` (Enter = none). Or `setup --exclude secrets
|
|
51
|
+
--exclude dist`. `setup` writes them into the client config, so there is no
|
|
52
|
+
JSON to edit; running it again keeps them, `--no-exclude` removes them.
|
|
53
|
+
- **Set by you only.** The agent cannot change them, and the analyzed project
|
|
54
|
+
cannot either: there is no `.sentinelignore`, because a repository could use
|
|
55
|
+
it to hide its own files.
|
|
56
|
+
- `analyze_file` refuses an excluded path whether or not it exists;
|
|
57
|
+
`analyze_directory` only counts excluded entries in `skipped.excluded`,
|
|
58
|
+
because their names can be private too. `doctor` lists the exclusions and
|
|
59
|
+
how many files and folders they skip in the project.
|
|
60
|
+
|
|
28
61
|
**0.4.2** makes `analyze_directory` short by default:
|
|
29
62
|
|
|
30
63
|
- **Short entries:** each file is `{path, risk, analysisStatus, language,
|
|
@@ -89,7 +122,7 @@ of safety. Other languages are not supported yet. What is detected in Python
|
|
|
89
122
|
|---|---|---|
|
|
90
123
|
| `analyze_code` | `code` (string), optional `target` (file name) | compact IR of the given source |
|
|
91
124
|
| `analyze_file` | `path` | compact IR of one file inside the allowed folders |
|
|
92
|
-
| `analyze_directory` | optional `path`, `max_files` (default 50, max 500), `recursive` (default `true`), `detail` (`"summary"` default, or `"full"`) | one short entry per file (path, risk, status, language, lines, operations, up to 3 risk signals; `"full"`: all facts), sorted from highest to lowest risk, plus `riskCounts`, `statusCounts` and `incomplete` (paths whose analysis did not finish; check them separately, their low risk is not evidence of safety); files that could not be read are listed in `skipped.unreadable` and are not counted as analyzed
|
|
125
|
+
| `analyze_directory` | optional `path`, `max_files` (default 50, max 500), `recursive` (default `true`), `detail` (`"summary"` default, or `"full"`) | one short entry per file (path, risk, status, language, lines, operations, up to 3 risk signals; `"full"`: all facts), sorted from highest to lowest risk, plus `riskCounts`, `statusCounts` and `incomplete` (paths whose analysis did not finish; check them separately, their low risk is not evidence of safety); files that could not be read are listed in `skipped.unreadable` and are not counted as analyzed; excluded files and folders are only counted in `skipped.excluded`; `truncated` and `skipped` |
|
|
93
126
|
|
|
94
127
|
Every tool returns the result twice: as JSON text (for clients that only read
|
|
95
128
|
text) and as `structuredContent`. All three are annotated
|
|
@@ -144,6 +177,8 @@ npx -y sentinel-ir-mcp@0.4 guide # optional: text for AGENTS
|
|
|
144
177
|
refuses your home folder or a whole disk;
|
|
145
178
|
- writes `sentinel-ir-mcp@0.4`, so clients get 0.4.x fixes but no unexpected
|
|
146
179
|
new features;
|
|
180
|
+
- asks which files or folders the agent must not read (`--exclude <name>`,
|
|
181
|
+
repeatable, skips the question; see [Excluding files](#excluding-files));
|
|
147
182
|
- for VS Code and Claude Code it writes nothing and prints their own install
|
|
148
183
|
command (`code --add-mcp ...`, `claude mcp add ...`) instead.
|
|
149
184
|
|
|
@@ -164,12 +199,14 @@ command. What was measured on which system: [COMPATIBILITY.md](COMPATIBILITY.md)
|
|
|
164
199
|
`"sentinel-ir"` entry that starts `sentinel-ir-mcp`, existing folders), then
|
|
165
200
|
starts **its own** server with the same folders, performs a real MCP
|
|
166
201
|
handshake, expects exactly the three tools and calls `analyze_directory` with
|
|
167
|
-
`max_files: 1`. It
|
|
202
|
+
`max_files: 1`. It lists your exclusions, how many files and folders they skip
|
|
203
|
+
in the project right now, and warns about one that matches nothing (a typo).
|
|
204
|
+
It never runs the command stored in the client config. Exit
|
|
168
205
|
code 0 when everything passes, 1 otherwise; `--json` gives a machine-readable
|
|
169
206
|
report.
|
|
170
207
|
|
|
171
208
|
Only `setup` writes files, and only the client config. The MCP server itself
|
|
172
|
-
|
|
209
|
+
reads only; the one exception is the [audit log](#audit-log), if you turn it on.
|
|
173
210
|
|
|
174
211
|
**Cursor:** a server from a project `.cursor/mcp.json` starts switched off.
|
|
175
212
|
Open Cursor Settings → Customize → MCPs and turn on the switch next to
|
|
@@ -274,10 +311,84 @@ Without folder arguments the server uses the client's
|
|
|
274
311
|
[MCP roots](https://modelcontextprotocol.io/specification/latest/client/roots)
|
|
275
312
|
if the client offers them; if it offers none, only `analyze_code` works.
|
|
276
313
|
|
|
314
|
+
### Excluding files
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
npx -y sentinel-ir-mcp@0.4 setup --client cursor --exclude secrets --exclude "*.min.js"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
| you write | excludes |
|
|
321
|
+
|---|---|
|
|
322
|
+
| `secrets` | every file or folder named `secrets`, anywhere in the project, with everything inside |
|
|
323
|
+
| `config/prod.js` | only this path, relative to the project folder |
|
|
324
|
+
| `*.min.js` | files by mask; `*` and `?` stay within one name |
|
|
325
|
+
| `generated/**/out` | `**` stands for any number of folders |
|
|
326
|
+
|
|
327
|
+
- Only additions: there is no `!` to re-include something. Absolute paths,
|
|
328
|
+
`..` and the characters `" & | < > ^ % !` are refused.
|
|
329
|
+
- Windows `\` works like `/`. On Windows and macOS letter case does not
|
|
330
|
+
matter (`SECRETS/a.js` is excluded by `secrets`), on Linux it does.
|
|
331
|
+
- The check uses the real path, so `lib/../secrets/a.js`, a symlink to an
|
|
332
|
+
excluded file and a Windows short name (`SECRE~1`) are refused too. It is
|
|
333
|
+
repeated when the file is opened.
|
|
334
|
+
- `analyze_code` is not affected: it analyzes text the agent already has.
|
|
335
|
+
|
|
336
|
+
The exclusions only bind this server. An agent that can read files with
|
|
337
|
+
other tools of its client is told about them, but this server cannot stop it.
|
|
338
|
+
|
|
339
|
+
### Audit log
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
npx -y sentinel-ir-mcp@0.4 setup --client cursor --audit-log # or answer "y" in setup
|
|
343
|
+
npx -y sentinel-ir-mcp@0.4 log # what the agent asked for
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
| system | folder |
|
|
347
|
+
|---|---|
|
|
348
|
+
| Windows | `%LOCALAPPDATA%\sentinel-ir-mcp\audit-log\` |
|
|
349
|
+
| macOS | `~/Library/Logs/sentinel-ir-mcp/` |
|
|
350
|
+
| Linux | `~/.local/state/sentinel-ir-mcp/audit-log/` (or `$XDG_STATE_HOME`) |
|
|
351
|
+
|
|
352
|
+
`--audit-log=<folder>` uses another folder: a full path outside the project
|
|
353
|
+
(the characters `" & | < > ^ % !` are refused because Windows starts the
|
|
354
|
+
server through `cmd`). Then read it with `log --dir <folder>`.
|
|
355
|
+
|
|
356
|
+
- **What is written:** one JSON line per tool call in `audit-YYYY-MM-DD.jsonl`
|
|
357
|
+
(local date): time, client name, tool, the path the agent asked for, the
|
|
358
|
+
result (risk, `analysisStatus`, file counts, up to 20 `critical`/`high` file
|
|
359
|
+
paths of a folder) or why it was refused (`excluded`, `outside`,
|
|
360
|
+
`not-found`, `too-large`), and the duration. A line when the server starts.
|
|
361
|
+
- **What is not written:** source code. For `analyze_code` only the size in
|
|
362
|
+
bytes, the language and the risk. Names of excluded files are not written
|
|
363
|
+
either, only their count. Long values are cut to 300 characters.
|
|
364
|
+
- **Cleanup:** day files older than 30 days are deleted; a day stops 1 MB
|
|
365
|
+
below 50 MB with one line saying so, so parallel servers stay under 50 MB
|
|
366
|
+
too. Other files in the folder are never touched.
|
|
367
|
+
- **Set by you only,** like exclusions: the agent and the project cannot turn
|
|
368
|
+
it on, off or move it. The log is never written inside the project.
|
|
369
|
+
- **Safe to read:** a path with a newline cannot add a fake line (JSON
|
|
370
|
+
escapes it), and `log` shows control and bidi characters as `\u{1b}`, so a
|
|
371
|
+
path cannot recolor or rewrite your terminal.
|
|
372
|
+
- **Never breaks the tools:** when the log cannot be written (disk full, no
|
|
373
|
+
permission) the tools answer as usual and the server says so once on stderr;
|
|
374
|
+
`doctor` reports it as `[FAIL]`. Several servers can share the folder: each
|
|
375
|
+
line is one append, so lines do not mix.
|
|
376
|
+
- `doctor` adds one line, `doctor checked that the log can be written`, and
|
|
377
|
+
none of its own tool calls.
|
|
378
|
+
- A day file that is a symlink, junction or hard link is not written to, on
|
|
379
|
+
Windows too, so the log cannot be used to overwrite another file.
|
|
380
|
+
- It records only what the agent asked **this server** for. Other file tools
|
|
381
|
+
of your AI app are not in it.
|
|
382
|
+
- Files are created readable by you only (`0600`, folder `0700`; on Windows
|
|
383
|
+
the folder in `%LOCALAPPDATA%` is private to your account).
|
|
384
|
+
|
|
277
385
|
### Options
|
|
278
386
|
|
|
279
387
|
```text
|
|
280
|
-
sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
388
|
+
sentinel-ir-mcp [--exclude <name>]... [--audit-log[=<folder>]] [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
389
|
+
|
|
390
|
+
--exclude <name> file or folder the file tools refuse (repeatable, max 100); see Excluding files
|
|
391
|
+
--audit-log[=<folder>] keep a log of the tool calls (never source code); see Audit log
|
|
281
392
|
|
|
282
393
|
--max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB, max 2097152 = 2 MiB)
|
|
283
394
|
--time-budget-ms <n> analyze_directory starts no new file after this (default 30000, max 600000)
|
|
@@ -286,14 +397,19 @@ sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>]
|
|
|
286
397
|
|
|
287
398
|
## What the server can and cannot do
|
|
288
399
|
|
|
289
|
-
- **Reads only.** The MCP server never writes files, runs commands or
|
|
290
|
-
network connections
|
|
291
|
-
|
|
400
|
+
- **Reads only.** The MCP server never writes project files, runs commands or
|
|
401
|
+
opens network connections. It writes only the [audit log](#audit-log) when
|
|
402
|
+
you turn it on (outside the project). The separate `setup` command writes
|
|
403
|
+
only the client config file you confirm; `doctor` starts this package's own server. The only files it opens are `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts`/`.tsx`/`.py`/`.pyi` files up to
|
|
292
404
|
`--max-file-bytes`.
|
|
293
405
|
- **Only inside the allowed folders.** Every path is resolved to its real path
|
|
294
406
|
(symlinks followed) and must lie inside a folder given on the command line,
|
|
295
407
|
or, without folder arguments, inside one of the client's MCP roots. `../`,
|
|
296
408
|
absolute paths elsewhere and symlinks pointing outside are refused.
|
|
409
|
+
- **Exclusions are yours.** They come only from the command line the client
|
|
410
|
+
starts (written by `setup`), never from the agent or from files in the
|
|
411
|
+
project. Excluded paths are refused by `analyze_file` and counted, not
|
|
412
|
+
named, in `skipped.excluded`.
|
|
297
413
|
- **`analyze_directory`** skips `node_modules`, `.git`, hidden folders and
|
|
298
414
|
every symlink, and stops after `max_files` files (`truncated: true`) or
|
|
299
415
|
when the time budget is spent (`timedOut: true`, rest in `notAnalyzed`).
|
package/bin/sentinel-ir-mcp.js
CHANGED
|
@@ -8,29 +8,41 @@ const fs = require("fs");
|
|
|
8
8
|
const path = require("path");
|
|
9
9
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
10
10
|
const { createServer, DEFAULT_LIMITS } = require("../src/server");
|
|
11
|
+
const { normalizePattern, ExcludeError, MAX_PATTERNS } = require("../src/exclude");
|
|
12
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects } = require("../src/auditLog");
|
|
11
13
|
const { version } = require("../package.json");
|
|
12
14
|
|
|
13
|
-
const USAGE = `sentinel-ir-mcp ${version} — MCP server for sentinel-ir (JavaScript and
|
|
15
|
+
const USAGE = `sentinel-ir-mcp ${version} — MCP server for sentinel-ir (JavaScript, TypeScript and Python)
|
|
14
16
|
|
|
15
17
|
Usage: sentinel-ir-mcp [options] [folder ...]
|
|
16
|
-
sentinel-ir-mcp setup | doctor | guide [--help]
|
|
18
|
+
sentinel-ir-mcp setup | doctor | guide | log [--help]
|
|
17
19
|
|
|
18
20
|
setup Connect Sentinel-IR to an AI client (shows the change first)
|
|
19
21
|
doctor Check Node.js, the client config and a real MCP connection
|
|
20
22
|
guide Print short usage instructions for AI agents
|
|
23
|
+
log Show what the agent asked for (needs --audit-log)
|
|
21
24
|
|
|
22
25
|
folder Folder(s) the file tools may read. A folder named like a
|
|
23
|
-
command must be written as ./setup, ./doctor
|
|
24
|
-
server uses the MCP roots offered by the client; if the
|
|
26
|
+
command must be written as ./setup, ./doctor, ./guide or
|
|
27
|
+
./log. Without folders the server uses the MCP roots offered by the client; if the
|
|
25
28
|
client offers none, only analyze_code works.
|
|
29
|
+
--exclude <name> A file or folder the file tools must refuse; repeat for more.
|
|
30
|
+
secrets = any file or folder named so, config/prod.js =
|
|
31
|
+
this path in an allowed folder, *.min.js = by mask,
|
|
32
|
+
** = any folders. Set by you; the project cannot change it.
|
|
33
|
+
--audit-log Write a log of what the agent asks this server for (paths,
|
|
34
|
+
results, refusals; never source code) to
|
|
35
|
+
${defaultAuditDir()}
|
|
36
|
+
--audit-log=<folder> The same log in another folder (a full path outside the project)
|
|
26
37
|
--max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes}, max ${DEFAULT_LIMITS.hardMaxFileBytes})
|
|
27
38
|
--time-budget-ms <n> analyze_directory starts no new file after this time (default ${DEFAULT_LIMITS.timeBudgetMs}, max ${DEFAULT_LIMITS.hardMaxTimeBudgetMs})
|
|
28
39
|
--max-files <n> Default file limit for analyze_directory (default ${DEFAULT_LIMITS.maxFiles}, max ${DEFAULT_LIMITS.hardMaxFiles})
|
|
29
40
|
-h, --help Show this help
|
|
30
41
|
-v, --version Show the version
|
|
31
42
|
|
|
32
|
-
The server only reads files. It never
|
|
33
|
-
|
|
43
|
+
The server only reads files. It never executes code or uses the network, and it
|
|
44
|
+
writes nothing except the audit log when you turn it on. "setup" writes only the
|
|
45
|
+
client config file you confirm. See the log with: sentinel-ir-mcp log`;
|
|
34
46
|
|
|
35
47
|
function die(message) {
|
|
36
48
|
process.stderr.write(`sentinel-ir-mcp: ${message}\n\n${USAGE}\n`);
|
|
@@ -44,7 +56,7 @@ const NUMBER_OPTIONS = {
|
|
|
44
56
|
};
|
|
45
57
|
|
|
46
58
|
function parseArgs(argv) {
|
|
47
|
-
const opts = { roots: [] };
|
|
59
|
+
const opts = { roots: [], exclude: [] };
|
|
48
60
|
for (let i = 0; i < argv.length; i++) {
|
|
49
61
|
let arg = argv[i];
|
|
50
62
|
let value;
|
|
@@ -57,6 +69,19 @@ function parseArgs(argv) {
|
|
|
57
69
|
const n = Number(value);
|
|
58
70
|
if (!Number.isInteger(n) || n < 1 || n > max) die(`${arg} must be an integer between 1 and ${max}`);
|
|
59
71
|
opts[key] = n;
|
|
72
|
+
} else if (arg === "--exclude") {
|
|
73
|
+
if (value === undefined) value = argv[++i];
|
|
74
|
+
if (value === undefined) die("--exclude needs a value");
|
|
75
|
+
try { opts.exclude.push(normalizePattern(value)); } catch (e) {
|
|
76
|
+
if (!(e instanceof ExcludeError)) throw e;
|
|
77
|
+
die(e.message);
|
|
78
|
+
}
|
|
79
|
+
if (opts.exclude.length > MAX_PATTERNS) die(`at most ${MAX_PATTERNS} --exclude options`);
|
|
80
|
+
} else if (arg === "--audit-log") {
|
|
81
|
+
try { opts.auditDir = value === undefined ? defaultAuditDir() : normalizeAuditDir(value); } catch (e) {
|
|
82
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
83
|
+
die(e.message);
|
|
84
|
+
}
|
|
60
85
|
} else if (arg.startsWith("-")) {
|
|
61
86
|
die(`unknown option ${arg}`);
|
|
62
87
|
} else {
|
|
@@ -67,6 +92,12 @@ function parseArgs(argv) {
|
|
|
67
92
|
opts.roots.push(dir);
|
|
68
93
|
}
|
|
69
94
|
}
|
|
95
|
+
if (opts.auditDir) {
|
|
96
|
+
try { assertOutsideProjects(opts.auditDir, opts.roots); } catch (e) {
|
|
97
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
98
|
+
die(e.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
70
101
|
return opts;
|
|
71
102
|
}
|
|
72
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinel-ir-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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.
|
|
22
|
+
"sentinel-ir": "^0.4.5",
|
|
23
23
|
"zod": "^4.1.0"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
package/src/analyze.js
CHANGED
|
@@ -5,6 +5,7 @@ const { constants, realpathSync } = require("fs");
|
|
|
5
5
|
const path = require("path");
|
|
6
6
|
const { analyzeAsync, compress } = require("sentinel-ir");
|
|
7
7
|
const { AccessError, resolveInside, isInside, isSourceFile } = require("./paths");
|
|
8
|
+
const { isExcluded, NONE } = require("./exclude");
|
|
8
9
|
|
|
9
10
|
// O_NONBLOCK: opening a FIFO swapped in after listing must not block the
|
|
10
11
|
// server forever. Neither flag exists on Windows; the inode check covers that.
|
|
@@ -12,12 +13,29 @@ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants
|
|
|
12
13
|
|
|
13
14
|
class TooLargeError extends AccessError {}
|
|
14
15
|
class HardlinkError extends AccessError {}
|
|
16
|
+
class ExcludedError extends AccessError {}
|
|
17
|
+
|
|
18
|
+
function excludedError(label) {
|
|
19
|
+
return new ExcludedError(`Excluded by the user's settings: ${label}. Do not try to read it another way.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Checked before the path is looked up, so the answer is the same whether or
|
|
23
|
+
// not the excluded file exists, and again on the real path (symlinks, "..",
|
|
24
|
+
// Windows short names, letter case).
|
|
25
|
+
function resolveAllowed(requested, roots, excluder) {
|
|
26
|
+
if (excluder.active && roots.length && typeof requested === "string" && requested.trim() !== "" && !requested.includes("\0")) {
|
|
27
|
+
if (isExcluded(path.resolve(roots[0], requested), roots, excluder)) throw excludedError(requested);
|
|
28
|
+
}
|
|
29
|
+
const resolved = resolveInside(requested, roots);
|
|
30
|
+
if (isExcluded(resolved.real, roots, excluder)) throw excludedError(requested);
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
// Reads the file that is actually opened, not the one checked earlier: the
|
|
17
35
|
// path may be swapped for a symlink between the check and the read. The open
|
|
18
36
|
// handle must be a regular file whose inode is the one now found at a real
|
|
19
37
|
// path inside the allowed folders, and at most maxFileBytes are read.
|
|
20
|
-
async function readInside(full, roots, maxFileBytes, label) {
|
|
38
|
+
async function readInside(full, roots, maxFileBytes, label, excluder = NONE) {
|
|
21
39
|
let handle;
|
|
22
40
|
try {
|
|
23
41
|
handle = await fs.open(full, OPEN_FLAGS);
|
|
@@ -38,6 +56,7 @@ async function readInside(full, roots, maxFileBytes, label) {
|
|
|
38
56
|
if (!roots.some((r) => isInside(real, r)) || current.dev !== opened.dev || current.ino !== opened.ino) {
|
|
39
57
|
throw new AccessError(`Access denied: ${label} changed while it was being read`);
|
|
40
58
|
}
|
|
59
|
+
if (isExcluded(real, roots, excluder)) throw excludedError(label);
|
|
41
60
|
// Another name of the same file may lie outside the allowed folders,
|
|
42
61
|
// and where it lies cannot be found out, so such files are refused.
|
|
43
62
|
// Both counts: the handle's alone can read 1 while the path is swapped.
|
|
@@ -60,6 +79,10 @@ async function readInside(full, roots, maxFileBytes, label) {
|
|
|
60
79
|
}
|
|
61
80
|
}
|
|
62
81
|
|
|
82
|
+
function realNative(p) {
|
|
83
|
+
try { return realpathSync.native(p); } catch { return p; }
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", "venv", "site-packages"]);
|
|
64
87
|
const RISK_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
65
88
|
|
|
@@ -71,8 +94,8 @@ async function analyzeCode(code, target, language) {
|
|
|
71
94
|
return compress(ir);
|
|
72
95
|
}
|
|
73
96
|
|
|
74
|
-
async function analyzeFile(requested, roots, { maxFileBytes }) {
|
|
75
|
-
const { real, display } =
|
|
97
|
+
async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE }) {
|
|
98
|
+
const { real, display } = resolveAllowed(requested, roots, excluder);
|
|
76
99
|
const stat = await fs.stat(real);
|
|
77
100
|
if (!stat.isFile()) throw new AccessError(`Not a file: ${requested}`);
|
|
78
101
|
if (!isSourceFile(real)) {
|
|
@@ -81,15 +104,19 @@ async function analyzeFile(requested, roots, { maxFileBytes }) {
|
|
|
81
104
|
if (stat.size > maxFileBytes) {
|
|
82
105
|
throw new AccessError(`File too large: ${requested} is ${stat.size} bytes, limit is ${maxFileBytes}`);
|
|
83
106
|
}
|
|
84
|
-
const code = await readInside(real, roots, maxFileBytes, requested);
|
|
107
|
+
const code = await readInside(real, roots, maxFileBytes, requested, excluder);
|
|
85
108
|
return analyzeCode(code, display);
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
// Breadth-first, sorted walk. Symlinks are never followed; node_modules, .git,
|
|
89
|
-
// __pycache__, venv, site-packages and hidden folders are skipped.
|
|
90
|
-
|
|
112
|
+
// __pycache__, venv, site-packages and hidden folders are skipped. Excluded
|
|
113
|
+
// files and folders are only counted: their names can be private too.
|
|
114
|
+
async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive, excluder = NONE }) {
|
|
91
115
|
const files = [];
|
|
92
|
-
const skipped = { tooLarge: [], symlinks: 0, unreadable: [], hardlinks: [] };
|
|
116
|
+
const skipped = { excluded: 0, tooLarge: [], symlinks: 0, unreadable: [], hardlinks: [] };
|
|
117
|
+
// Exclusions match the real names: on Windows dir may be spelled with
|
|
118
|
+
// short names (SRC~1) that the patterns do not know.
|
|
119
|
+
const prefix = excluder.active ? path.relative(realNative(root), realNative(dir)) : "";
|
|
93
120
|
let truncated = false;
|
|
94
121
|
const queue = [dir];
|
|
95
122
|
while (queue.length && !truncated) {
|
|
@@ -107,7 +134,9 @@ async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
|
|
|
107
134
|
for (const entry of entries) {
|
|
108
135
|
const full = path.join(current, entry.name);
|
|
109
136
|
const rel = path.relative(root, full).split(path.sep).join("/");
|
|
110
|
-
if (
|
|
137
|
+
if (excluder.active && excluder.matches(path.join(prefix, path.relative(dir, full)))) {
|
|
138
|
+
skipped.excluded++;
|
|
139
|
+
} else if (entry.isSymbolicLink()) {
|
|
111
140
|
skipped.symlinks++;
|
|
112
141
|
} else if (entry.isDirectory()) {
|
|
113
142
|
if (recursive && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) queue.push(full);
|
|
@@ -192,14 +221,14 @@ function fullEntry(rel, ir) {
|
|
|
192
221
|
async function analyzeDirectory(
|
|
193
222
|
requested,
|
|
194
223
|
roots,
|
|
195
|
-
{ maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity, detail = "summary" }
|
|
224
|
+
{ maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity, detail = "summary", excluder = NONE }
|
|
196
225
|
) {
|
|
197
226
|
const started = Date.now();
|
|
198
|
-
const { real, root, display } =
|
|
227
|
+
const { real, root, display } = resolveAllowed(requested, roots, excluder);
|
|
199
228
|
const stat = await fs.stat(real);
|
|
200
229
|
if (!stat.isDirectory()) throw new AccessError(`Not a folder: ${requested}`);
|
|
201
230
|
|
|
202
|
-
const { files, skipped, truncated } = await collectFiles(real, root, { maxFiles, maxFileBytes, recursive });
|
|
231
|
+
const { files, skipped, truncated } = await collectFiles(real, root, { maxFiles, maxFileBytes, recursive, excluder });
|
|
203
232
|
const results = [];
|
|
204
233
|
const notAnalyzed = [];
|
|
205
234
|
for (const { full, rel } of files) {
|
|
@@ -212,8 +241,12 @@ async function analyzeDirectory(
|
|
|
212
241
|
// One file that cannot be read or analyzed must not hide the others.
|
|
213
242
|
let ir;
|
|
214
243
|
try {
|
|
215
|
-
ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel), rel);
|
|
244
|
+
ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel, excluder), rel);
|
|
216
245
|
} catch (err) {
|
|
246
|
+
if (err instanceof ExcludedError) {
|
|
247
|
+
skipped.excluded++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
217
250
|
const list = err instanceof TooLargeError ? skipped.tooLarge
|
|
218
251
|
: err instanceof HardlinkError ? skipped.hardlinks : skipped.unreadable;
|
|
219
252
|
list.push(rel);
|
package/src/auditLog.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { homeOf } = require("./clients");
|
|
6
|
+
|
|
7
|
+
// An optional record, for the user, of what the agent asked this server for.
|
|
8
|
+
// One JSON object per line in one file per local day; never any source code.
|
|
9
|
+
// Turned on only by the user (--audit-log on the command line the client
|
|
10
|
+
// starts), never by the agent or by files in the project.
|
|
11
|
+
|
|
12
|
+
const FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
13
|
+
const KEEP_DAYS = 30;
|
|
14
|
+
const MAX_DAY_BYTES = 50 * 1024 * 1024;
|
|
15
|
+
// A day stops this far below the limit, so lines that parallel servers append
|
|
16
|
+
// in the same instant still end below MAX_DAY_BYTES (a line is at most ~10 kB).
|
|
17
|
+
const DAY_RESERVE = 1024 * 1024;
|
|
18
|
+
const MAX_TEXT = 300;
|
|
19
|
+
const MAX_LISTED = 20;
|
|
20
|
+
// Characters cmd.exe acts on: an MCP entry on Windows runs through "cmd /c".
|
|
21
|
+
const UNSAFE_DIR = /["&|<>^%!\u0000-\u001f]/;
|
|
22
|
+
const OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
|
|
23
|
+
|
|
24
|
+
class AuditLogError extends Error {}
|
|
25
|
+
|
|
26
|
+
function defaultAuditDir(env = process.env, platform = process.platform) {
|
|
27
|
+
if (platform === "win32") {
|
|
28
|
+
const local = env.LOCALAPPDATA || path.join(homeOf(env), "AppData", "Local");
|
|
29
|
+
return path.join(local, "sentinel-ir-mcp", "audit-log");
|
|
30
|
+
}
|
|
31
|
+
if (platform === "darwin") return path.join(homeOf(env), "Library", "Logs", "sentinel-ir-mcp");
|
|
32
|
+
const state = env.XDG_STATE_HOME || path.join(homeOf(env), ".local", "state");
|
|
33
|
+
return path.join(state, "sentinel-ir-mcp", "audit-log");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "--audit-log" (default folder) or "--audit-log=<folder>"; must be absolute
|
|
37
|
+
// because a client starts the server from a folder you do not choose.
|
|
38
|
+
function normalizeAuditDir(raw) {
|
|
39
|
+
if (typeof raw !== "string" || raw.trim() === "") throw new AuditLogError("--audit-log=<folder> needs a folder");
|
|
40
|
+
const dir = raw.trim();
|
|
41
|
+
if (UNSAFE_DIR.test(dir)) throw new AuditLogError(`audit log folder "${raw}" contains a character that is not allowed (" & | < > ^ % !)`);
|
|
42
|
+
if (!path.isAbsolute(dir)) throw new AuditLogError(`audit log folder "${raw}" must be a full path, e.g. ${process.platform === "win32" ? "C:\\logs\\sentinel" : "/home/me/logs"}`);
|
|
43
|
+
return path.resolve(dir);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isInside(child, parent) {
|
|
47
|
+
const rel = path.relative(parent, child);
|
|
48
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function realOrResolved(p) {
|
|
52
|
+
// The folder may not exist yet: resolve its nearest existing parent.
|
|
53
|
+
let current = path.resolve(p);
|
|
54
|
+
const rest = [];
|
|
55
|
+
for (;;) {
|
|
56
|
+
try { return path.join(fs.realpathSync(current), ...rest.reverse()); } catch { /* go up */ }
|
|
57
|
+
const parent = path.dirname(current);
|
|
58
|
+
if (parent === current) return path.resolve(p);
|
|
59
|
+
rest.push(path.basename(current));
|
|
60
|
+
current = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A log inside the project could be committed or read by the agent.
|
|
65
|
+
function assertOutsideProjects(dir, projects, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
|
|
66
|
+
const fold = (p) => (caseInsensitive ? p.toLowerCase() : p);
|
|
67
|
+
const real = fold(realOrResolved(dir));
|
|
68
|
+
for (const project of projects) {
|
|
69
|
+
if (isInside(real, fold(realOrResolved(project)))) {
|
|
70
|
+
throw new AuditLogError(`the audit log folder ${dir} is inside the project folder ${project}; pick a folder outside it`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pad(n) { return String(n).padStart(2, "0"); }
|
|
76
|
+
function dayName(date) { return `audit-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.jsonl`; }
|
|
77
|
+
|
|
78
|
+
function cut(value, max = MAX_TEXT) {
|
|
79
|
+
if (typeof value !== "string") return value;
|
|
80
|
+
if (value.length <= max) return value;
|
|
81
|
+
let end = max;
|
|
82
|
+
const code = value.charCodeAt(end - 1);
|
|
83
|
+
if (code >= 0xd800 && code <= 0xdbff) end--;
|
|
84
|
+
return value.slice(0, end) + "…";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function logFiles(dir) {
|
|
88
|
+
let names;
|
|
89
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
90
|
+
return names.filter((n) => FILE_RE.test(n)).sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pruneOld(dir, now) {
|
|
94
|
+
const limit = new Date(now);
|
|
95
|
+
limit.setHours(0, 0, 0, 0);
|
|
96
|
+
limit.setDate(limit.getDate() - KEEP_DAYS);
|
|
97
|
+
for (const name of logFiles(dir)) {
|
|
98
|
+
const [, y, m, d] = FILE_RE.exec(name);
|
|
99
|
+
if (new Date(Number(y), Number(m) - 1, Number(d)) < limit) {
|
|
100
|
+
try {
|
|
101
|
+
if (fs.lstatSync(path.join(dir, name)).isFile()) fs.unlinkSync(path.join(dir, name));
|
|
102
|
+
} catch { /* another server may have removed it */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What a tool call is logged as: what was asked and the outcome, no code.
|
|
108
|
+
function describeCall(tool, args, outcome) {
|
|
109
|
+
const entry = { tool };
|
|
110
|
+
if (tool === "analyze_code") {
|
|
111
|
+
entry.bytes = Buffer.byteLength(typeof args.code === "string" ? args.code : "", "utf8");
|
|
112
|
+
if (typeof args.target === "string") entry.target = cut(args.target);
|
|
113
|
+
if (typeof args.language === "string") entry.language = args.language;
|
|
114
|
+
} else {
|
|
115
|
+
entry.path = cut(typeof args.path === "string" ? args.path : ".");
|
|
116
|
+
if (tool === "analyze_directory") {
|
|
117
|
+
for (const [k, v] of [["max_files", args.max_files], ["recursive", args.recursive], ["detail", args.detail]]) if (v !== undefined) entry[k] = v;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (outcome.error) {
|
|
121
|
+
entry.ok = false;
|
|
122
|
+
entry.reason = outcome.reason;
|
|
123
|
+
entry.message = cut(outcome.error);
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
entry.ok = true;
|
|
127
|
+
const r = outcome.result;
|
|
128
|
+
if (tool === "analyze_directory") {
|
|
129
|
+
entry.analyzed = r.analyzed;
|
|
130
|
+
entry.riskCounts = r.riskCounts;
|
|
131
|
+
entry.incomplete = r.incomplete.length;
|
|
132
|
+
entry.excluded = r.skipped.excluded;
|
|
133
|
+
const notRead = r.skipped.tooLarge.length + r.skipped.unreadable.length + r.skipped.hardlinks.length;
|
|
134
|
+
if (notRead) entry.notRead = notRead;
|
|
135
|
+
if (r.truncated) entry.truncated = true;
|
|
136
|
+
if (r.timedOut) entry.timedOut = true;
|
|
137
|
+
const top = r.files.filter((f) => f.risk === "critical" || f.risk === "high").slice(0, MAX_LISTED);
|
|
138
|
+
if (top.length) entry.top = top.map((f) => ({ path: cut(f.path), risk: f.risk }));
|
|
139
|
+
} else {
|
|
140
|
+
entry.risk = r.world && r.world.risk;
|
|
141
|
+
entry.analysisStatus = r.analysisStatus;
|
|
142
|
+
entry.language = r.language;
|
|
143
|
+
if (tool === "analyze_file" && r.mission && typeof r.mission.target === "string") entry.file = cut(r.mission.target);
|
|
144
|
+
}
|
|
145
|
+
return entry;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createAuditLog({ dir, now = () => Date.now(), onError = (m) => process.stderr.write(`sentinel-ir-mcp: ${m}\n`) } = {}) {
|
|
149
|
+
if (!dir) return { active: false, record() {}, flush: async () => {} };
|
|
150
|
+
let queue = Promise.resolve();
|
|
151
|
+
let prunedFor = null;
|
|
152
|
+
let fullDay = null;
|
|
153
|
+
let failed = false;
|
|
154
|
+
|
|
155
|
+
async function append(line, file) {
|
|
156
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
157
|
+
// Once per server and day, so a server running for weeks still cleans up.
|
|
158
|
+
if (prunedFor !== file) { prunedFor = file; pruneOld(dir, now()); }
|
|
159
|
+
// Windows has no O_NOFOLLOW, so the checks below refuse a day file that
|
|
160
|
+
// is a symlink, junction or hard link on every system before any write.
|
|
161
|
+
const before = await fs.promises.lstat(file, { bigint: true }).catch((e) => {
|
|
162
|
+
if (e.code === "ENOENT") return null;
|
|
163
|
+
throw e;
|
|
164
|
+
});
|
|
165
|
+
if (before && !before.isFile()) throw new Error(`${file} is not a regular file`);
|
|
166
|
+
const handle = await fs.promises.open(file, OPEN_FLAGS, 0o600);
|
|
167
|
+
try {
|
|
168
|
+
const stat = await handle.stat({ bigint: true });
|
|
169
|
+
if (!stat.isFile()) throw new Error(`${file} is not a regular file`);
|
|
170
|
+
if (stat.nlink > 1n) throw new Error(`${file} has a second name (hard link); not writing`);
|
|
171
|
+
const after = await fs.promises.lstat(file, { bigint: true });
|
|
172
|
+
const same = (a) => a.isFile() && a.ino === stat.ino && a.dev === stat.dev;
|
|
173
|
+
if (!same(after) || (before && !same(before))) throw new Error(`${file} was replaced while opening; not writing`);
|
|
174
|
+
if (stat.size + BigInt(Buffer.byteLength(line)) > BigInt(MAX_DAY_BYTES - DAY_RESERVE)) {
|
|
175
|
+
if (fullDay !== file) {
|
|
176
|
+
fullDay = file;
|
|
177
|
+
await handle.write(JSON.stringify({ ts: new Date(now()).toISOString(), event: "full", limitBytes: MAX_DAY_BYTES }) + "\n");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// One write per line: with O_APPEND lines of parallel servers do not interleave.
|
|
182
|
+
await handle.write(line);
|
|
183
|
+
} finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function record(entry) {
|
|
189
|
+
const date = new Date(now());
|
|
190
|
+
const line = JSON.stringify({ ts: date.toISOString(), pid: process.pid, ...entry }) + "\n";
|
|
191
|
+
const file = path.join(dir, dayName(date));
|
|
192
|
+
queue = queue.then(() => append(line, file)).then(() => { failed = false; }, (err) => {
|
|
193
|
+
if (!failed) onError(`cannot write the audit log in ${dir}: ${err.message}`);
|
|
194
|
+
failed = true;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { active: true, dir, record, flush: () => queue };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
AuditLogError, KEEP_DAYS, MAX_DAY_BYTES, FILE_RE,
|
|
203
|
+
defaultAuditDir, normalizeAuditDir, assertOutsideProjects, createAuditLog, describeCall, logFiles, dayName, cut
|
|
204
|
+
};
|