sentinel-ir-mcp 0.4.3 → 0.4.5
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 +86 -5
- package/bin/sentinel-ir-mcp.js +24 -6
- package/package.json +2 -2
- package/src/analyze.js +10 -5
- package/src/auditLog.js +230 -0
- package/src/cli.js +27 -5
- package/src/clients.js +34 -13
- package/src/doctor.js +57 -8
- package/src/logView.js +158 -0
- package/src/server.js +48 -10
- package/src/setup.js +52 -5
package/README.md
CHANGED
|
@@ -25,6 +25,29 @@ 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.5** adds to that log the SHA-256 of every file the server read, so you
|
|
29
|
+
can check later which version of a file the agent was shown (see
|
|
30
|
+
[Audit log](#audit-log)).
|
|
31
|
+
|
|
32
|
+
**0.4.4** can keep a log of what the agent asked for (off unless you turn it on):
|
|
33
|
+
|
|
34
|
+
- **One question in `setup`:** *"Keep a log of what the agent asks for (file
|
|
35
|
+
names and results, never your code)? [y/N]"*. Or `setup --audit-log`;
|
|
36
|
+
`--no-audit-log` turns it off. Running `setup` again keeps the setting.
|
|
37
|
+
- **One command to read it:** `npx -y sentinel-ir-mcp@0.4 log` prints the last
|
|
38
|
+
50 entries as plain lines, `--all` everything, `--json` for scripts:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
2026-10-18 10:32:05 [cursor] file src/app.js: critical
|
|
42
|
+
2026-10-18 10:32:09 [cursor] REFUSED file secrets/aws.js: excluded by your settings
|
|
43
|
+
2026-10-18 10:33:40 [cursor] folder .: 42 file(s) (1 critical, 6 medium, 35 low), 3 excluded
|
|
44
|
+
critical src/app.js
|
|
45
|
+
```
|
|
46
|
+
- `doctor` says whether the log is on, where it is and how big, and proves the
|
|
47
|
+
log can be written. Details: [Audit log](#audit-log).
|
|
48
|
+
|
|
49
|
+
Also uses `sentinel-ir` 0.4.5 (Python parse errors point where Python does).
|
|
50
|
+
|
|
28
51
|
**0.4.3** lets you exclude files and folders:
|
|
29
52
|
|
|
30
53
|
- **One question in `setup`:** *"Files or folders the agent must not read"*,
|
|
@@ -187,7 +210,7 @@ code 0 when everything passes, 1 otherwise; `--json` gives a machine-readable
|
|
|
187
210
|
report.
|
|
188
211
|
|
|
189
212
|
Only `setup` writes files, and only the client config. The MCP server itself
|
|
190
|
-
|
|
213
|
+
reads only; the one exception is the [audit log](#audit-log), if you turn it on.
|
|
191
214
|
|
|
192
215
|
**Cursor:** a server from a project `.cursor/mcp.json` starts switched off.
|
|
193
216
|
Open Cursor Settings → Customize → MCPs and turn on the switch next to
|
|
@@ -317,12 +340,69 @@ npx -y sentinel-ir-mcp@0.4 setup --client cursor --exclude secrets --exclude "*.
|
|
|
317
340
|
The exclusions only bind this server. An agent that can read files with
|
|
318
341
|
other tools of its client is told about them, but this server cannot stop it.
|
|
319
342
|
|
|
343
|
+
### Audit log
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
npx -y sentinel-ir-mcp@0.4 setup --client cursor --audit-log # or answer "y" in setup
|
|
347
|
+
npx -y sentinel-ir-mcp@0.4 log # what the agent asked for
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
| system | folder |
|
|
351
|
+
|---|---|
|
|
352
|
+
| Windows | `%LOCALAPPDATA%\sentinel-ir-mcp\audit-log\` |
|
|
353
|
+
| macOS | `~/Library/Logs/sentinel-ir-mcp/` |
|
|
354
|
+
| Linux | `~/.local/state/sentinel-ir-mcp/audit-log/` (or `$XDG_STATE_HOME`) |
|
|
355
|
+
|
|
356
|
+
`--audit-log=<folder>` uses another folder: a full path outside the project
|
|
357
|
+
(the characters `" & | < > ^ % !` are refused because Windows starts the
|
|
358
|
+
server through `cmd`). Then read it with `log --dir <folder>`.
|
|
359
|
+
|
|
360
|
+
- **What is written:** one JSON line per tool call in `audit-YYYY-MM-DD.jsonl`
|
|
361
|
+
(local date): time, client name, tool, the path the agent asked for, the
|
|
362
|
+
result (risk, `analysisStatus`, file counts, up to 20 `critical`/`high` file
|
|
363
|
+
paths of a folder) or why it was refused (`excluded`, `outside`,
|
|
364
|
+
`not-found`, `too-large`), and the duration. A line when the server starts.
|
|
365
|
+
- **Which version of a file (0.4.5):** for every file the server read, the
|
|
366
|
+
SHA-256 of the exact bytes it analyzed: `sha256` for `analyze_file`; for
|
|
367
|
+
`analyze_directory` the folder line gets a `call` id and `hashed` count,
|
|
368
|
+
and the hashes follow in `"event": "hashes"` lines (40 files each, same
|
|
369
|
+
`call` id), so no line gets long. `log` shows the first 12 digits for
|
|
370
|
+
files and the risky files of a folder; `log --json` has all of them. To check whether a file is still the
|
|
371
|
+
one the agent saw, compare with `certutil -hashfile <file> SHA256`
|
|
372
|
+
(Windows), `shasum -a 256 <file>` (macOS) or `sha256sum <file>` (Linux).
|
|
373
|
+
Pasted code (`analyze_code`) gets no hash: a very short snippet could be
|
|
374
|
+
guessed from it. The tool answers to the agent are unchanged.
|
|
375
|
+
- **What is not written:** source code. For `analyze_code` only the size in
|
|
376
|
+
bytes, the language and the risk. Names of excluded files are not written
|
|
377
|
+
either, only their count. Long values are cut to 300 characters.
|
|
378
|
+
- **Cleanup:** day files older than 30 days are deleted; a day stops 1 MB
|
|
379
|
+
below 50 MB with one line saying so, so parallel servers stay under 50 MB
|
|
380
|
+
too. Other files in the folder are never touched.
|
|
381
|
+
- **Set by you only,** like exclusions: the agent and the project cannot turn
|
|
382
|
+
it on, off or move it. The log is never written inside the project.
|
|
383
|
+
- **Safe to read:** a path with a newline cannot add a fake line (JSON
|
|
384
|
+
escapes it), and `log` shows control and bidi characters as `\u{1b}`, so a
|
|
385
|
+
path cannot recolor or rewrite your terminal.
|
|
386
|
+
- **Never breaks the tools:** when the log cannot be written (disk full, no
|
|
387
|
+
permission) the tools answer as usual and the server says so once on stderr;
|
|
388
|
+
`doctor` reports it as `[FAIL]`. Several servers can share the folder: each
|
|
389
|
+
line is one append, so lines do not mix.
|
|
390
|
+
- `doctor` adds one line, `doctor checked that the log can be written`, and
|
|
391
|
+
none of its own tool calls.
|
|
392
|
+
- A day file that is a symlink, junction or hard link is not written to, on
|
|
393
|
+
Windows too, so the log cannot be used to overwrite another file.
|
|
394
|
+
- It records only what the agent asked **this server** for. Other file tools
|
|
395
|
+
of your AI app are not in it.
|
|
396
|
+
- Files are created readable by you only (`0600`, folder `0700`; on Windows
|
|
397
|
+
the folder in `%LOCALAPPDATA%` is private to your account).
|
|
398
|
+
|
|
320
399
|
### Options
|
|
321
400
|
|
|
322
401
|
```text
|
|
323
|
-
sentinel-ir-mcp [--exclude <name>]... [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
402
|
+
sentinel-ir-mcp [--exclude <name>]... [--audit-log[=<folder>]] [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
324
403
|
|
|
325
404
|
--exclude <name> file or folder the file tools refuse (repeatable, max 100); see Excluding files
|
|
405
|
+
--audit-log[=<folder>] keep a log of the tool calls (never source code); see Audit log
|
|
326
406
|
|
|
327
407
|
--max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB, max 2097152 = 2 MiB)
|
|
328
408
|
--time-budget-ms <n> analyze_directory starts no new file after this (default 30000, max 600000)
|
|
@@ -331,9 +411,10 @@ sentinel-ir-mcp [--exclude <name>]... [--max-file-bytes <n>] [--max-files <n>] [
|
|
|
331
411
|
|
|
332
412
|
## What the server can and cannot do
|
|
333
413
|
|
|
334
|
-
- **Reads only.** The MCP server never writes files, runs commands or
|
|
335
|
-
network connections
|
|
336
|
-
|
|
414
|
+
- **Reads only.** The MCP server never writes project files, runs commands or
|
|
415
|
+
opens network connections. It writes only the [audit log](#audit-log) when
|
|
416
|
+
you turn it on (outside the project). The separate `setup` command writes
|
|
417
|
+
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
|
|
337
418
|
`--max-file-bytes`.
|
|
338
419
|
- **Only inside the allowed folders.** Every path is resolved to its real path
|
|
339
420
|
(symlinks followed) and must lie inside a folder given on the command line,
|
package/bin/sentinel-ir-mcp.js
CHANGED
|
@@ -9,33 +9,40 @@ const path = require("path");
|
|
|
9
9
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
10
10
|
const { createServer, DEFAULT_LIMITS } = require("../src/server");
|
|
11
11
|
const { normalizePattern, ExcludeError, MAX_PATTERNS } = require("../src/exclude");
|
|
12
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects } = require("../src/auditLog");
|
|
12
13
|
const { version } = require("../package.json");
|
|
13
14
|
|
|
14
|
-
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)
|
|
15
16
|
|
|
16
17
|
Usage: sentinel-ir-mcp [options] [folder ...]
|
|
17
|
-
sentinel-ir-mcp setup | doctor | guide [--help]
|
|
18
|
+
sentinel-ir-mcp setup | doctor | guide | log [--help]
|
|
18
19
|
|
|
19
20
|
setup Connect Sentinel-IR to an AI client (shows the change first)
|
|
20
21
|
doctor Check Node.js, the client config and a real MCP connection
|
|
21
22
|
guide Print short usage instructions for AI agents
|
|
23
|
+
log Show what the agent asked for (needs --audit-log)
|
|
22
24
|
|
|
23
25
|
folder Folder(s) the file tools may read. A folder named like a
|
|
24
|
-
command must be written as ./setup, ./doctor
|
|
25
|
-
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
|
|
26
28
|
client offers none, only analyze_code works.
|
|
27
29
|
--exclude <name> A file or folder the file tools must refuse; repeat for more.
|
|
28
30
|
secrets = any file or folder named so, config/prod.js =
|
|
29
31
|
this path in an allowed folder, *.min.js = by mask,
|
|
30
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)
|
|
31
37
|
--max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes}, max ${DEFAULT_LIMITS.hardMaxFileBytes})
|
|
32
38
|
--time-budget-ms <n> analyze_directory starts no new file after this time (default ${DEFAULT_LIMITS.timeBudgetMs}, max ${DEFAULT_LIMITS.hardMaxTimeBudgetMs})
|
|
33
39
|
--max-files <n> Default file limit for analyze_directory (default ${DEFAULT_LIMITS.maxFiles}, max ${DEFAULT_LIMITS.hardMaxFiles})
|
|
34
40
|
-h, --help Show this help
|
|
35
41
|
-v, --version Show the version
|
|
36
42
|
|
|
37
|
-
The server only reads files. It never
|
|
38
|
-
|
|
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`;
|
|
39
46
|
|
|
40
47
|
function die(message) {
|
|
41
48
|
process.stderr.write(`sentinel-ir-mcp: ${message}\n\n${USAGE}\n`);
|
|
@@ -70,6 +77,11 @@ function parseArgs(argv) {
|
|
|
70
77
|
die(e.message);
|
|
71
78
|
}
|
|
72
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
|
+
}
|
|
73
85
|
} else if (arg.startsWith("-")) {
|
|
74
86
|
die(`unknown option ${arg}`);
|
|
75
87
|
} else {
|
|
@@ -80,6 +92,12 @@ function parseArgs(argv) {
|
|
|
80
92
|
opts.roots.push(dir);
|
|
81
93
|
}
|
|
82
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
|
+
}
|
|
83
101
|
return opts;
|
|
84
102
|
}
|
|
85
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.5",
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const fs = require("fs/promises");
|
|
4
|
+
const { createHash } = require("crypto");
|
|
4
5
|
const { constants, realpathSync } = require("fs");
|
|
5
6
|
const path = require("path");
|
|
6
7
|
const { analyzeAsync, compress } = require("sentinel-ir");
|
|
@@ -35,7 +36,7 @@ function resolveAllowed(requested, roots, excluder) {
|
|
|
35
36
|
// path may be swapped for a symlink between the check and the read. The open
|
|
36
37
|
// handle must be a regular file whose inode is the one now found at a real
|
|
37
38
|
// path inside the allowed folders, and at most maxFileBytes are read.
|
|
38
|
-
async function readInside(full, roots, maxFileBytes, label, excluder = NONE) {
|
|
39
|
+
async function readInside(full, roots, maxFileBytes, label, excluder = NONE, onRead = null) {
|
|
39
40
|
let handle;
|
|
40
41
|
try {
|
|
41
42
|
handle = await fs.open(full, OPEN_FLAGS);
|
|
@@ -73,6 +74,8 @@ async function readInside(full, roots, maxFileBytes, label, excluder = NONE) {
|
|
|
73
74
|
}
|
|
74
75
|
if (length > maxFileBytes) throw new TooLargeError(`File too large: ${label} grew past the limit of ${maxFileBytes} bytes`);
|
|
75
76
|
if (length === buffer.length) throw new AccessError(`Access denied: ${label} changed while it was being read`);
|
|
77
|
+
// The audit log records which bytes were read, never the bytes.
|
|
78
|
+
if (onRead) onRead(createHash("sha256").update(buffer.subarray(0, length)).digest("hex"));
|
|
76
79
|
return buffer.toString("utf8", 0, length);
|
|
77
80
|
} finally {
|
|
78
81
|
await handle.close();
|
|
@@ -94,7 +97,7 @@ async function analyzeCode(code, target, language) {
|
|
|
94
97
|
return compress(ir);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE }) {
|
|
100
|
+
async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE, hashes = null }) {
|
|
98
101
|
const { real, display } = resolveAllowed(requested, roots, excluder);
|
|
99
102
|
const stat = await fs.stat(real);
|
|
100
103
|
if (!stat.isFile()) throw new AccessError(`Not a file: ${requested}`);
|
|
@@ -104,7 +107,7 @@ async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE })
|
|
|
104
107
|
if (stat.size > maxFileBytes) {
|
|
105
108
|
throw new AccessError(`File too large: ${requested} is ${stat.size} bytes, limit is ${maxFileBytes}`);
|
|
106
109
|
}
|
|
107
|
-
const code = await readInside(real, roots, maxFileBytes, requested, excluder);
|
|
110
|
+
const code = await readInside(real, roots, maxFileBytes, requested, excluder, hashes && ((h) => hashes.set(display, h)));
|
|
108
111
|
return analyzeCode(code, display);
|
|
109
112
|
}
|
|
110
113
|
|
|
@@ -221,7 +224,7 @@ function fullEntry(rel, ir) {
|
|
|
221
224
|
async function analyzeDirectory(
|
|
222
225
|
requested,
|
|
223
226
|
roots,
|
|
224
|
-
{ maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity, detail = "summary", excluder = NONE }
|
|
227
|
+
{ maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity, detail = "summary", excluder = NONE, hashes = null }
|
|
225
228
|
) {
|
|
226
229
|
const started = Date.now();
|
|
227
230
|
const { real, root, display } = resolveAllowed(requested, roots, excluder);
|
|
@@ -241,7 +244,9 @@ async function analyzeDirectory(
|
|
|
241
244
|
// One file that cannot be read or analyzed must not hide the others.
|
|
242
245
|
let ir;
|
|
243
246
|
try {
|
|
244
|
-
|
|
247
|
+
let hash = null;
|
|
248
|
+
ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel, excluder, hashes && ((h) => { hash = h; })), rel);
|
|
249
|
+
if (hash) hashes.set(rel, hash);
|
|
245
250
|
} catch (err) {
|
|
246
251
|
if (err instanceof ExcludedError) {
|
|
247
252
|
skipped.excluded++;
|
package/src/auditLog.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { homeOf } = require("./clients");
|
|
7
|
+
|
|
8
|
+
// An optional record, for the user, of what the agent asked this server for.
|
|
9
|
+
// One JSON object per line in one file per local day; never any source code.
|
|
10
|
+
// Turned on only by the user (--audit-log on the command line the client
|
|
11
|
+
// starts), never by the agent or by files in the project.
|
|
12
|
+
|
|
13
|
+
const FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
14
|
+
const KEEP_DAYS = 30;
|
|
15
|
+
const MAX_DAY_BYTES = 50 * 1024 * 1024;
|
|
16
|
+
// A day stops this far below the limit, so lines that parallel servers append
|
|
17
|
+
// in the same instant still end below MAX_DAY_BYTES (a line is at most ~10 kB).
|
|
18
|
+
const DAY_RESERVE = 1024 * 1024;
|
|
19
|
+
const MAX_TEXT = 300;
|
|
20
|
+
const MAX_LISTED = 20;
|
|
21
|
+
const HASHES_PER_LINE = 40;
|
|
22
|
+
// Characters cmd.exe acts on: an MCP entry on Windows runs through "cmd /c".
|
|
23
|
+
const UNSAFE_DIR = /["&|<>^%!\u0000-\u001f]/;
|
|
24
|
+
const OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
|
|
25
|
+
|
|
26
|
+
class AuditLogError extends Error {}
|
|
27
|
+
|
|
28
|
+
function defaultAuditDir(env = process.env, platform = process.platform) {
|
|
29
|
+
if (platform === "win32") {
|
|
30
|
+
const local = env.LOCALAPPDATA || path.join(homeOf(env), "AppData", "Local");
|
|
31
|
+
return path.join(local, "sentinel-ir-mcp", "audit-log");
|
|
32
|
+
}
|
|
33
|
+
if (platform === "darwin") return path.join(homeOf(env), "Library", "Logs", "sentinel-ir-mcp");
|
|
34
|
+
const state = env.XDG_STATE_HOME || path.join(homeOf(env), ".local", "state");
|
|
35
|
+
return path.join(state, "sentinel-ir-mcp", "audit-log");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// "--audit-log" (default folder) or "--audit-log=<folder>"; must be absolute
|
|
39
|
+
// because a client starts the server from a folder you do not choose.
|
|
40
|
+
function normalizeAuditDir(raw) {
|
|
41
|
+
if (typeof raw !== "string" || raw.trim() === "") throw new AuditLogError("--audit-log=<folder> needs a folder");
|
|
42
|
+
const dir = raw.trim();
|
|
43
|
+
if (UNSAFE_DIR.test(dir)) throw new AuditLogError(`audit log folder "${raw}" contains a character that is not allowed (" & | < > ^ % !)`);
|
|
44
|
+
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"}`);
|
|
45
|
+
return path.resolve(dir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isInside(child, parent) {
|
|
49
|
+
const rel = path.relative(parent, child);
|
|
50
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function realOrResolved(p) {
|
|
54
|
+
// The folder may not exist yet: resolve its nearest existing parent.
|
|
55
|
+
let current = path.resolve(p);
|
|
56
|
+
const rest = [];
|
|
57
|
+
for (;;) {
|
|
58
|
+
try { return path.join(fs.realpathSync(current), ...rest.reverse()); } catch { /* go up */ }
|
|
59
|
+
const parent = path.dirname(current);
|
|
60
|
+
if (parent === current) return path.resolve(p);
|
|
61
|
+
rest.push(path.basename(current));
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A log inside the project could be committed or read by the agent.
|
|
67
|
+
function assertOutsideProjects(dir, projects, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
|
|
68
|
+
const fold = (p) => (caseInsensitive ? p.toLowerCase() : p);
|
|
69
|
+
const real = fold(realOrResolved(dir));
|
|
70
|
+
for (const project of projects) {
|
|
71
|
+
if (isInside(real, fold(realOrResolved(project)))) {
|
|
72
|
+
throw new AuditLogError(`the audit log folder ${dir} is inside the project folder ${project}; pick a folder outside it`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pad(n) { return String(n).padStart(2, "0"); }
|
|
78
|
+
function dayName(date) { return `audit-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.jsonl`; }
|
|
79
|
+
|
|
80
|
+
function cut(value, max = MAX_TEXT) {
|
|
81
|
+
if (typeof value !== "string") return value;
|
|
82
|
+
if (value.length <= max) return value;
|
|
83
|
+
let end = max;
|
|
84
|
+
const code = value.charCodeAt(end - 1);
|
|
85
|
+
if (code >= 0xd800 && code <= 0xdbff) end--;
|
|
86
|
+
return value.slice(0, end) + "…";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function logFiles(dir) {
|
|
90
|
+
let names;
|
|
91
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
92
|
+
return names.filter((n) => FILE_RE.test(n)).sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pruneOld(dir, now) {
|
|
96
|
+
const limit = new Date(now);
|
|
97
|
+
limit.setHours(0, 0, 0, 0);
|
|
98
|
+
limit.setDate(limit.getDate() - KEEP_DAYS);
|
|
99
|
+
for (const name of logFiles(dir)) {
|
|
100
|
+
const [, y, m, d] = FILE_RE.exec(name);
|
|
101
|
+
if (new Date(Number(y), Number(m) - 1, Number(d)) < limit) {
|
|
102
|
+
try {
|
|
103
|
+
if (fs.lstatSync(path.join(dir, name)).isFile()) fs.unlinkSync(path.join(dir, name));
|
|
104
|
+
} catch { /* another server may have removed it */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// What a tool call is logged as: what was asked and the outcome, no code.
|
|
110
|
+
function describeCall(tool, args, outcome) {
|
|
111
|
+
const entry = { tool };
|
|
112
|
+
if (tool === "analyze_code") {
|
|
113
|
+
entry.bytes = Buffer.byteLength(typeof args.code === "string" ? args.code : "", "utf8");
|
|
114
|
+
if (typeof args.target === "string") entry.target = cut(args.target);
|
|
115
|
+
if (typeof args.language === "string") entry.language = args.language;
|
|
116
|
+
} else {
|
|
117
|
+
entry.path = cut(typeof args.path === "string" ? args.path : ".");
|
|
118
|
+
if (tool === "analyze_directory") {
|
|
119
|
+
for (const [k, v] of [["max_files", args.max_files], ["recursive", args.recursive], ["detail", args.detail]]) if (v !== undefined) entry[k] = v;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (outcome.error) {
|
|
123
|
+
entry.ok = false;
|
|
124
|
+
entry.reason = outcome.reason;
|
|
125
|
+
entry.message = cut(outcome.error);
|
|
126
|
+
return entry;
|
|
127
|
+
}
|
|
128
|
+
entry.ok = true;
|
|
129
|
+
const r = outcome.result;
|
|
130
|
+
if (tool === "analyze_directory") {
|
|
131
|
+
entry.analyzed = r.analyzed;
|
|
132
|
+
entry.riskCounts = r.riskCounts;
|
|
133
|
+
entry.incomplete = r.incomplete.length;
|
|
134
|
+
entry.excluded = r.skipped.excluded;
|
|
135
|
+
const notRead = r.skipped.tooLarge.length + r.skipped.unreadable.length + r.skipped.hardlinks.length;
|
|
136
|
+
if (notRead) entry.notRead = notRead;
|
|
137
|
+
if (r.truncated) entry.truncated = true;
|
|
138
|
+
if (r.timedOut) entry.timedOut = true;
|
|
139
|
+
// sha256 of every analyzed file, so the user can check later which
|
|
140
|
+
// version of each file the agent was shown. splitHashes moves the
|
|
141
|
+
// full list to lines of its own.
|
|
142
|
+
const hashes = args.hashes instanceof Map ? args.hashes : new Map();
|
|
143
|
+
const withHash = (f) => (hashes.has(f.path) ? { sha256: hashes.get(f.path) } : {});
|
|
144
|
+
const top = r.files.filter((f) => f.risk === "critical" || f.risk === "high").slice(0, MAX_LISTED);
|
|
145
|
+
if (top.length) entry.top = top.map((f) => ({ path: cut(f.path), risk: f.risk, ...withHash(f) }));
|
|
146
|
+
const files = r.files.filter((f) => hashes.has(f.path)).map((f) => ({ path: cut(f.path), risk: f.risk, ...withHash(f) }));
|
|
147
|
+
if (files.length) entry.files = files;
|
|
148
|
+
} else {
|
|
149
|
+
entry.risk = r.world && r.world.risk;
|
|
150
|
+
entry.analysisStatus = r.analysisStatus;
|
|
151
|
+
entry.language = r.language;
|
|
152
|
+
if (tool === "analyze_file" && r.mission && typeof r.mission.target === "string") entry.file = cut(r.mission.target);
|
|
153
|
+
if (tool === "analyze_file" && args.hashes instanceof Map && args.hashes.size) entry.sha256 = [...args.hashes.values()][0];
|
|
154
|
+
}
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A folder can have 500 files: their hashes go in follow-up lines of at most
|
|
159
|
+
// HASHES_PER_LINE files (usually under 20 kB each, so DAY_RESERVE still holds and
|
|
160
|
+
// one write stays one line), linked to the call by a random "call" id.
|
|
161
|
+
function splitHashes(entry) {
|
|
162
|
+
if (!Array.isArray(entry.files)) return [entry];
|
|
163
|
+
const { files, ...main } = entry;
|
|
164
|
+
main.call = crypto.randomBytes(6).toString("hex");
|
|
165
|
+
main.hashed = files.length;
|
|
166
|
+
const parts = Math.ceil(files.length / HASHES_PER_LINE);
|
|
167
|
+
const more = [];
|
|
168
|
+
for (let i = 0; i < parts; i++) {
|
|
169
|
+
more.push({ event: "hashes", call: main.call, part: i + 1, parts, files: files.slice(i * HASHES_PER_LINE, (i + 1) * HASHES_PER_LINE) });
|
|
170
|
+
}
|
|
171
|
+
return [main, ...more];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createAuditLog({ dir, now = () => Date.now(), onError = (m) => process.stderr.write(`sentinel-ir-mcp: ${m}\n`) } = {}) {
|
|
175
|
+
if (!dir) return { active: false, record() {}, flush: async () => {} };
|
|
176
|
+
let queue = Promise.resolve();
|
|
177
|
+
let prunedFor = null;
|
|
178
|
+
let fullDay = null;
|
|
179
|
+
let failed = false;
|
|
180
|
+
|
|
181
|
+
async function append(line, file) {
|
|
182
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
183
|
+
// Once per server and day, so a server running for weeks still cleans up.
|
|
184
|
+
if (prunedFor !== file) { prunedFor = file; pruneOld(dir, now()); }
|
|
185
|
+
// Windows has no O_NOFOLLOW, so the checks below refuse a day file that
|
|
186
|
+
// is a symlink, junction or hard link on every system before any write.
|
|
187
|
+
const before = await fs.promises.lstat(file, { bigint: true }).catch((e) => {
|
|
188
|
+
if (e.code === "ENOENT") return null;
|
|
189
|
+
throw e;
|
|
190
|
+
});
|
|
191
|
+
if (before && !before.isFile()) throw new Error(`${file} is not a regular file`);
|
|
192
|
+
const handle = await fs.promises.open(file, OPEN_FLAGS, 0o600);
|
|
193
|
+
try {
|
|
194
|
+
const stat = await handle.stat({ bigint: true });
|
|
195
|
+
if (!stat.isFile()) throw new Error(`${file} is not a regular file`);
|
|
196
|
+
if (stat.nlink > 1n) throw new Error(`${file} has a second name (hard link); not writing`);
|
|
197
|
+
const after = await fs.promises.lstat(file, { bigint: true });
|
|
198
|
+
const same = (a) => a.isFile() && a.ino === stat.ino && a.dev === stat.dev;
|
|
199
|
+
if (!same(after) || (before && !same(before))) throw new Error(`${file} was replaced while opening; not writing`);
|
|
200
|
+
if (stat.size + BigInt(Buffer.byteLength(line)) > BigInt(MAX_DAY_BYTES - DAY_RESERVE)) {
|
|
201
|
+
if (fullDay !== file) {
|
|
202
|
+
fullDay = file;
|
|
203
|
+
await handle.write(JSON.stringify({ ts: new Date(now()).toISOString(), event: "full", limitBytes: MAX_DAY_BYTES }) + "\n");
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// One write per line: with O_APPEND lines of parallel servers do not interleave.
|
|
208
|
+
await handle.write(line);
|
|
209
|
+
} finally {
|
|
210
|
+
await handle.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function record(entry) {
|
|
215
|
+
const date = new Date(now());
|
|
216
|
+
const line = JSON.stringify({ ts: date.toISOString(), pid: process.pid, ...entry }) + "\n";
|
|
217
|
+
const file = path.join(dir, dayName(date));
|
|
218
|
+
queue = queue.then(() => append(line, file)).then(() => { failed = false; }, (err) => {
|
|
219
|
+
if (!failed) onError(`cannot write the audit log in ${dir}: ${err.message}`);
|
|
220
|
+
failed = true;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { active: true, dir, record, flush: () => queue };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
AuditLogError, KEEP_DAYS, MAX_DAY_BYTES, FILE_RE,
|
|
229
|
+
defaultAuditDir, normalizeAuditDir, assertOutsideProjects, createAuditLog, describeCall, splitHashes, HASHES_PER_LINE, logFiles, dayName, cut
|
|
230
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -4,12 +4,13 @@ const readline = require("readline");
|
|
|
4
4
|
const { runSetup } = require("./setup");
|
|
5
5
|
const { runDoctor } = require("./doctor");
|
|
6
6
|
const { GUIDE } = require("./guide");
|
|
7
|
+
const { runLog } = require("./logView");
|
|
7
8
|
const { CLIENT_NAMES, PACKAGE_SPEC } = require("./clients");
|
|
8
9
|
|
|
9
|
-
const COMMANDS = Object.freeze(["setup", "doctor", "guide"]);
|
|
10
|
+
const COMMANDS = Object.freeze(["setup", "doctor", "guide", "log"]);
|
|
10
11
|
|
|
11
12
|
const HELP = {
|
|
12
|
-
setup: `Usage: sentinel-ir-mcp setup [--client <name>] [--project <folder>] [--exclude <name>]... [--no-exclude] [--global] [--config <file>] [--dry-run] [--yes]
|
|
13
|
+
setup: `Usage: sentinel-ir-mcp setup [--client <name>] [--project <folder>] [--exclude <name>]... [--no-exclude] [--audit-log[=<folder>]] [--no-audit-log] [--global] [--config <file>] [--dry-run] [--yes]
|
|
13
14
|
|
|
14
15
|
Adds a "sentinel-ir" server to an AI client's MCP config. Shows the change first,
|
|
15
16
|
backs up the old file and touches only the "sentinel-ir" entry.
|
|
@@ -21,6 +22,11 @@ backs up the old file and touches only the "sentinel-ir" entry.
|
|
|
21
22
|
path, *.min.js = by mask. Without it, setup asks (Enter = none)
|
|
22
23
|
and running setup again keeps the current list.
|
|
23
24
|
--no-exclude remove all exclusions
|
|
25
|
+
--audit-log keep a log of what the agent asks for (never your code);
|
|
26
|
+
see it with "sentinel-ir-mcp log". Without it, setup asks
|
|
27
|
+
(Enter = no) and running setup again keeps the setting.
|
|
28
|
+
--audit-log=<folder> the same, in this folder (full path, outside the project)
|
|
29
|
+
--no-audit-log stop the log (files already written stay)
|
|
24
30
|
--global Cursor only: ~/.cursor/mcp.json instead of <project>/.cursor/mcp.json
|
|
25
31
|
--config <file> write this config file instead of the client's default
|
|
26
32
|
--dry-run show the change, write nothing
|
|
@@ -29,6 +35,14 @@ backs up the old file and touches only the "sentinel-ir" entry.
|
|
|
29
35
|
|
|
30
36
|
Checks Node.js, the client config and a real MCP connection to this server.
|
|
31
37
|
Exit code 0 when all checks pass, 1 otherwise.`,
|
|
38
|
+
log: `Usage: sentinel-ir-mcp log [--all] [--json] [--dir <folder>]
|
|
39
|
+
|
|
40
|
+
Shows what the agent asked this server for, newest last (the last 50 entries).
|
|
41
|
+
Needs the audit log turned on: sentinel-ir-mcp setup --audit-log
|
|
42
|
+
|
|
43
|
+
--all every entry of the last 30 days
|
|
44
|
+
--json the entries as JSON
|
|
45
|
+
--dir <folder> a log folder set with --audit-log=<folder>`,
|
|
32
46
|
guide: `Usage: sentinel-ir-mcp guide
|
|
33
47
|
|
|
34
48
|
Prints short instructions for AI agents (for AGENTS.md or client rules).
|
|
@@ -36,12 +50,16 @@ Nothing is written.`
|
|
|
36
50
|
};
|
|
37
51
|
|
|
38
52
|
const REPEATABLE = new Set(["--exclude"]);
|
|
53
|
+
// Flags that may carry a value with "=", but never take the next argument.
|
|
54
|
+
const OPTIONAL_VALUE = new Set(["--audit-log"]);
|
|
39
55
|
|
|
40
56
|
const FLAGS = {
|
|
41
57
|
setup: {
|
|
42
58
|
"--client": "client", "--project": "project", "--config": "config", "--exclude": "exclude",
|
|
43
|
-
"--no-exclude": true, "--
|
|
59
|
+
"--no-exclude": true, "--audit-log": "auditLog", "--no-audit-log": true,
|
|
60
|
+
"--global": true, "--dry-run": true, "--yes": true, "-y": true
|
|
44
61
|
},
|
|
62
|
+
log: { "--dir": "dir", "--all": true, "--json": true },
|
|
45
63
|
doctor: { "--client": "client", "--project": "project", "--config": "config", "--global": true, "--json": true },
|
|
46
64
|
guide: {}
|
|
47
65
|
};
|
|
@@ -49,7 +67,7 @@ const FLAGS = {
|
|
|
49
67
|
class UsageError extends Error {}
|
|
50
68
|
|
|
51
69
|
function camel(flag) {
|
|
52
|
-
return { "-y": "yes", "--dry-run": "dryRun", "--no-exclude": "noExclude" }[flag] || flag.replace(/^--/, "");
|
|
70
|
+
return { "-y": "yes", "--dry-run": "dryRun", "--no-exclude": "noExclude", "--no-audit-log": "noAuditLog" }[flag] || flag.replace(/^--/, "");
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
function parse(command, argv) {
|
|
@@ -61,7 +79,10 @@ function parse(command, argv) {
|
|
|
61
79
|
if (arg === "-h" || arg === "--help") return { help: true };
|
|
62
80
|
if (arg.startsWith("--") && arg.includes("=")) [arg, value] = [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)];
|
|
63
81
|
if (!Object.prototype.hasOwnProperty.call(spec, arg)) throw new UsageError(`unknown ${arg.startsWith("-") ? "option" : "argument"} ${arg}`);
|
|
64
|
-
if (
|
|
82
|
+
if (OPTIONAL_VALUE.has(arg)) {
|
|
83
|
+
if (value === "") throw new UsageError(`${arg}= needs a folder`);
|
|
84
|
+
opts[spec[arg]] = value === undefined ? true : value;
|
|
85
|
+
} else if (spec[arg] === true) {
|
|
65
86
|
if (value !== undefined) throw new UsageError(`${arg} takes no value`);
|
|
66
87
|
opts[camel(arg)] = true;
|
|
67
88
|
} else {
|
|
@@ -108,6 +129,7 @@ async function runCommand(command, argv, io = defaultIo()) {
|
|
|
108
129
|
return 0;
|
|
109
130
|
}
|
|
110
131
|
if (command === "setup") return runSetup(opts, io);
|
|
132
|
+
if (command === "log") return runLog(opts, io);
|
|
111
133
|
return runDoctor(opts, io);
|
|
112
134
|
}
|
|
113
135
|
|
package/src/clients.js
CHANGED
|
@@ -60,6 +60,26 @@ function excludeArgs(exclude = []) {
|
|
|
60
60
|
return exclude.map((p) => `--exclude=${p}`);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// audit: null = off, { dir: null } = the default folder, { dir } = that folder.
|
|
64
|
+
function auditArgs(audit) {
|
|
65
|
+
if (!audit) return [];
|
|
66
|
+
return [audit.dir ? `--audit-log=${audit.dir}` : "--audit-log"];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function serverFlags(exclude = [], audit = null) {
|
|
70
|
+
return [...excludeArgs(exclude), ...auditArgs(audit)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entryAuditLog(args) {
|
|
74
|
+
let audit = null;
|
|
75
|
+
for (const raw of args) {
|
|
76
|
+
const a = String(raw);
|
|
77
|
+
if (a === "--audit-log") audit = { dir: null };
|
|
78
|
+
else if (a.startsWith("--audit-log=")) audit = { dir: a.slice("--audit-log=".length) };
|
|
79
|
+
}
|
|
80
|
+
return audit;
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
// Exclusions in an entry's arguments, in both spellings the server accepts.
|
|
64
84
|
function entryExcludes(args) {
|
|
65
85
|
const out = [];
|
|
@@ -71,14 +91,14 @@ function entryExcludes(args) {
|
|
|
71
91
|
return out;
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
function npxEntry(folder, exclude = []) {
|
|
75
|
-
return { command: "npx", args: ["-y", PACKAGE_SPEC, ...
|
|
94
|
+
function npxEntry(folder, exclude = [], audit = null) {
|
|
95
|
+
return { command: "npx", args: ["-y", PACKAGE_SPEC, ...serverFlags(exclude, audit), folder] };
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
// On Windows npx is npx.cmd, which a client cannot start without a shell
|
|
79
99
|
// (measured: spawn("npx") fails with ENOENT), so the entry goes through cmd /c.
|
|
80
|
-
function serverEntry(folder, platform = process.platform, exclude = []) {
|
|
81
|
-
const { command, args } = npxEntry(folder, exclude);
|
|
100
|
+
function serverEntry(folder, platform = process.platform, exclude = [], audit = null) {
|
|
101
|
+
const { command, args } = npxEntry(folder, exclude, audit);
|
|
82
102
|
return platform === "win32" ? { command: "cmd", args: ["/c", command, ...args] } : { command, args };
|
|
83
103
|
}
|
|
84
104
|
|
|
@@ -100,8 +120,8 @@ function windowsArgQuote(value) {
|
|
|
100
120
|
return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`;
|
|
101
121
|
}
|
|
102
122
|
|
|
103
|
-
function vscodeConfig(project, exclude = []) {
|
|
104
|
-
return { name: SERVER_NAME, type: "stdio", ...npxEntry(project, exclude) };
|
|
123
|
+
function vscodeConfig(project, exclude = [], audit = null) {
|
|
124
|
+
return { name: SERVER_NAME, type: "stdio", ...npxEntry(project, exclude, audit) };
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
// code.cmd/claude.cmd re-read their arguments through cmd.exe, which acts on
|
|
@@ -113,22 +133,23 @@ function cmdSafeJson(value) {
|
|
|
113
133
|
|
|
114
134
|
// Windows forms were checked in CI against a .cmd shim (like code.cmd and
|
|
115
135
|
// claude.cmd) from PowerShell 5.1, PowerShell 7 and cmd.exe.
|
|
116
|
-
// Exclusions never contain " & | < > ^ % ! (
|
|
117
|
-
|
|
118
|
-
|
|
136
|
+
// Exclusions and audit log folders never contain " & | < > ^ % ! (both are
|
|
137
|
+
// checked before they get here).
|
|
138
|
+
function installCommand(client, project, platform = process.platform, shell = "powershell", exclude = [], audit = null) {
|
|
139
|
+
const json = JSON.stringify(vscodeConfig(project, exclude, audit));
|
|
119
140
|
const win = platform === "win32";
|
|
120
141
|
if (client === "vscode") {
|
|
121
142
|
if (!win) return `code --add-mcp ${shellQuote(json)}`;
|
|
122
|
-
return `code ${shell === "cmd" ? "" : "--% "}--add-mcp ${windowsArgQuote(cmdSafeJson(vscodeConfig(project, exclude)))}`;
|
|
143
|
+
return `code ${shell === "cmd" ? "" : "--% "}--add-mcp ${windowsArgQuote(cmdSafeJson(vscodeConfig(project, exclude, audit)))}`;
|
|
123
144
|
}
|
|
124
145
|
if (client === "claude-code") {
|
|
125
|
-
const ex =
|
|
146
|
+
const ex = serverFlags(exclude, audit);
|
|
126
147
|
if (win && shell !== "cmd") {
|
|
127
148
|
// PowerShell prefers npm's claude.ps1 shim: a script gets `--` only when quoted, and --% does not apply to it.
|
|
128
149
|
return `claude mcp add --transport stdio ${SERVER_NAME} '--' cmd /c npx -y ${PACKAGE_SPEC} ${ex.map((a) => powershellQuote(a) + " ").join("")}${powershellQuote(project)}`;
|
|
129
150
|
}
|
|
130
151
|
if (win && project.includes("%")) {
|
|
131
|
-
const config = { type: "stdio", ...serverEntry(project, "win32", exclude) };
|
|
152
|
+
const config = { type: "stdio", ...serverEntry(project, "win32", exclude, audit) };
|
|
132
153
|
return `claude mcp add-json ${SERVER_NAME} ${windowsArgQuote(cmdSafeJson(config))}`;
|
|
133
154
|
}
|
|
134
155
|
const quote = win ? (a) => `"${a}"` : shellQuote;
|
|
@@ -149,5 +170,5 @@ function installLinks() {
|
|
|
149
170
|
|
|
150
171
|
module.exports = {
|
|
151
172
|
SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, CLIENT_NAMES, FILE_CLIENTS,
|
|
152
|
-
homeOf, npxEntry, serverEntry, excludeArgs, entryExcludes, isWindowsNpxWrapper, shellQuote, powershellQuote, installCommand, installLinks
|
|
173
|
+
homeOf, npxEntry, serverEntry, excludeArgs, entryExcludes, serverFlags, entryAuditLog, isWindowsNpxWrapper, shellQuote, powershellQuote, installCommand, installLinks
|
|
153
174
|
};
|
package/src/doctor.js
CHANGED
|
@@ -5,7 +5,8 @@ const path = require("path");
|
|
|
5
5
|
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
|
|
6
6
|
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
7
7
|
const { version } = require("../package.json");
|
|
8
|
-
const { SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, FILE_CLIENTS, isWindowsNpxWrapper, entryExcludes,
|
|
8
|
+
const { SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, FILE_CLIENTS, isWindowsNpxWrapper, entryExcludes, entryAuditLog, serverFlags } = require("./clients");
|
|
9
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects, logFiles, dayName, createAuditLog } = require("./auditLog");
|
|
9
10
|
const { readConfig } = require("./setup");
|
|
10
11
|
const { createExcluder, ExcludeError } = require("./exclude");
|
|
11
12
|
const { SKIP_DIRS } = require("./analyze");
|
|
@@ -49,7 +50,7 @@ function checkExcludes(client, label, args, file, add) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
function checkConfig(client, file, explicit, project, add, platform) {
|
|
53
|
+
function checkConfig(client, file, explicit, project, add, platform, env) {
|
|
53
54
|
const label = CLIENTS[client].label;
|
|
54
55
|
if (!file) {
|
|
55
56
|
if (explicit) add("fail", `${client}.config`, `${label}: config location unknown on this system`, "Pass the config path with --config <file>.");
|
|
@@ -102,7 +103,39 @@ function checkConfig(client, file, explicit, project, add, platform) {
|
|
|
102
103
|
if (folders.length === 0) add("warn", `${client}.folder`, `${label}: no folder given; the file tools rely on the client's MCP roots`);
|
|
103
104
|
const exclude = checkExcludes(client, label, args, file, add);
|
|
104
105
|
if (exclude === null) return null;
|
|
105
|
-
|
|
106
|
+
let audit = entryAuditLog(args);
|
|
107
|
+
if (audit) {
|
|
108
|
+
try {
|
|
109
|
+
const dir = audit.dir ? normalizeAuditDir(audit.dir) : defaultAuditDir(env, platform);
|
|
110
|
+
assertOutsideProjects(dir, resolved);
|
|
111
|
+
audit = { dir: audit.dir ? dir : null, path: dir };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
114
|
+
add("fail", `${client}.audit`, `${label}: ${e.message}; the server will not start`,
|
|
115
|
+
`Run: npx -y ${PACKAGE_SPEC} setup --client ${client} --audit-log (or --no-audit-log)`);
|
|
116
|
+
return { folders: resolved, exclude, audit: null, auditBroken: true };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { folders: resolved, exclude, audit };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatBytes(n) {
|
|
123
|
+
if (n < 1024) return `${n} B`;
|
|
124
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`;
|
|
125
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function describeAuditFolder(dir) {
|
|
129
|
+
const files = logFiles(dir);
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
try { bytes += fs.statSync(path.join(dir, f)).size; } catch { /* removed meanwhile */ }
|
|
133
|
+
}
|
|
134
|
+
return files.length ? `${files.length} day(s), ${formatBytes(bytes)}` : "empty so far";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fileSize(file) {
|
|
138
|
+
try { return fs.statSync(file).size; } catch { return -1; }
|
|
106
139
|
}
|
|
107
140
|
|
|
108
141
|
function withTimeout(promise, ms, what) {
|
|
@@ -113,8 +146,13 @@ function withTimeout(promise, ms, what) {
|
|
|
113
146
|
]).finally(() => clearTimeout(timer));
|
|
114
147
|
}
|
|
115
148
|
|
|
116
|
-
async function checkHandshake(folders, exclude, add, timeoutMs) {
|
|
117
|
-
|
|
149
|
+
async function checkHandshake(folders, exclude, audit, add, timeoutMs) {
|
|
150
|
+
// Without the audit log, so the check does not fill it with its own calls;
|
|
151
|
+
// the log is proved below with one "check" line from the same writer.
|
|
152
|
+
const flags = serverFlags(exclude, null);
|
|
153
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [BIN, ...flags, ...folders], stderr: "pipe" });
|
|
154
|
+
const today = audit ? path.join(audit.path, dayName(new Date())) : null;
|
|
155
|
+
const before = today ? fileSize(today) : -1;
|
|
118
156
|
const client = new Client({ name: "sentinel-ir-mcp-doctor", version });
|
|
119
157
|
try {
|
|
120
158
|
await withTimeout(client.connect(transport), timeoutMs, "MCP handshake");
|
|
@@ -133,6 +171,13 @@ async function checkHandshake(folders, exclude, add, timeoutMs) {
|
|
|
133
171
|
} finally {
|
|
134
172
|
await client.close().catch(() => {});
|
|
135
173
|
}
|
|
174
|
+
if (!audit) return;
|
|
175
|
+
const log = createAuditLog({ dir: audit.path, onError: () => {} });
|
|
176
|
+
log.record({ event: "check", version });
|
|
177
|
+
await log.flush();
|
|
178
|
+
if (fileSize(today) > before) add("ok", "audit.write", `Audit log: can be written (doctor added one "check" line to ${today})`);
|
|
179
|
+
else add("fail", "audit.write", `Audit log: nothing could be written to ${audit.path}`,
|
|
180
|
+
"Check that the folder exists or can be created and that you may write to it (disk full?). The tools still work without the log.");
|
|
136
181
|
}
|
|
137
182
|
|
|
138
183
|
const MARK = { ok: "[ok] ", warn: "[warn]", fail: "[FAIL]", skip: "[skip]" };
|
|
@@ -155,6 +200,8 @@ async function runDoctor(opts, io) {
|
|
|
155
200
|
const names = opts.client ? (CLIENTS[opts.client] && CLIENTS[opts.client].kind === "file" ? [opts.client] : []) : FILE_CLIENTS;
|
|
156
201
|
let folders = null;
|
|
157
202
|
let exclude = [];
|
|
203
|
+
let audit = null;
|
|
204
|
+
let auditBroken = false;
|
|
158
205
|
for (const client of names) {
|
|
159
206
|
const ctx = { project, global: Boolean(opts.global), env: io.env, platform: io.platform };
|
|
160
207
|
let file = opts.config ? path.resolve(io.cwd, opts.config) : CLIENTS[client].configPath(ctx);
|
|
@@ -164,8 +211,8 @@ async function runDoctor(opts, io) {
|
|
|
164
211
|
`Current ${CLIENTS[client].label} reads ${file || "a new location"}. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
165
212
|
file = legacy;
|
|
166
213
|
}
|
|
167
|
-
const found = checkConfig(client, file, Boolean(opts.client), project, add, io.platform);
|
|
168
|
-
if (found && folders === null) ({ folders, exclude } = found);
|
|
214
|
+
const found = checkConfig(client, file, Boolean(opts.client), project, add, io.platform, io.env);
|
|
215
|
+
if (found && folders === null) ({ folders, exclude, audit, auditBroken = false } = found);
|
|
169
216
|
}
|
|
170
217
|
if (folders === null) {
|
|
171
218
|
let isDir = false;
|
|
@@ -177,7 +224,9 @@ async function runDoctor(opts, io) {
|
|
|
177
224
|
}
|
|
178
225
|
}
|
|
179
226
|
for (const line of describeExcluded(exclude, folders, SKIP_DIRS)) add("ok", "exclude", line);
|
|
180
|
-
|
|
227
|
+
if (audit) add("ok", "audit", `Audit log: on, in ${audit.path} (${describeAuditFolder(audit.path)}); see it: npx -y ${PACKAGE_SPEC} log${audit.dir ? ` --dir "${audit.dir}"` : ""}`);
|
|
228
|
+
else if (!auditBroken) add("ok", "audit", `Audit log: off (turn it on: npx -y ${PACKAGE_SPEC} setup --audit-log)`);
|
|
229
|
+
await checkHandshake(folders, exclude, audit, add, opts.timeoutMs || 30000);
|
|
181
230
|
|
|
182
231
|
const failed = checks.some((c) => c.status === "fail");
|
|
183
232
|
if (opts.json) {
|
package/src/logView.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { PACKAGE_SPEC } = require("./clients");
|
|
6
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, logFiles, KEEP_DAYS } = require("./auditLog");
|
|
7
|
+
|
|
8
|
+
const RECENT = 50;
|
|
9
|
+
// Control, bidi and line-separator characters could rewrite the terminal or
|
|
10
|
+
// fake a line: shown as \u{..} instead.
|
|
11
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
12
|
+
|
|
13
|
+
function safe(value) {
|
|
14
|
+
return String(value).replace(UNSAFE, (c) => `\\u{${c.codePointAt(0).toString(16)}}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const REASONS = {
|
|
18
|
+
excluded: "excluded by your settings",
|
|
19
|
+
outside: "outside the project folder",
|
|
20
|
+
"not-found": "does not exist",
|
|
21
|
+
"too-large": "file too large",
|
|
22
|
+
"no-folders": "no project folder is set"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TOOL_WORDS = { analyze_file: "file", analyze_directory: "folder", analyze_code: "pasted code" };
|
|
26
|
+
|
|
27
|
+
function pad(n) { return String(n).padStart(2, "0"); }
|
|
28
|
+
|
|
29
|
+
function localTime(ts) {
|
|
30
|
+
const d = new Date(ts);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return "????-??-?? ??:??:??";
|
|
32
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function riskCounts(counts) {
|
|
36
|
+
if (!counts || typeof counts !== "object") return "";
|
|
37
|
+
const parts = ["critical", "high", "medium", "low"].filter((k) => Number(counts[k]) > 0).map((k) => `${Number(counts[k])} ${k}`);
|
|
38
|
+
return parts.length ? ` (${parts.join(", ")})` : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
42
|
+
// The first 12 hex digits are enough to compare by eye; --json has all 64.
|
|
43
|
+
function shortHash(h) { return typeof h === "string" && SHA256.test(h) ? ` sha256 ${h.slice(0, 12)}` : ""; }
|
|
44
|
+
|
|
45
|
+
function describe(e) {
|
|
46
|
+
if (e.event === "start") {
|
|
47
|
+
const folders = Array.isArray(e.folders) && e.folders.length ? ` for ${e.folders.map(safe).join(", ")}` : "";
|
|
48
|
+
return [`server started (sentinel-ir-mcp ${safe(e.version)})${folders}`];
|
|
49
|
+
}
|
|
50
|
+
if (e.event === "check") return [`doctor checked that the log can be written`];
|
|
51
|
+
if (e.event === "full") return [`the log for this day reached its size limit; nothing more is written today`];
|
|
52
|
+
if (typeof e.event === "string") return [`${safe(e.event).slice(0, 40)} (see log --json)`];
|
|
53
|
+
const what = TOOL_WORDS[e.tool] ? TOOL_WORDS[e.tool] : safe(e.tool);
|
|
54
|
+
const target = e.tool === "analyze_code" ? `${what} (${Number(e.bytes) || 0} bytes${e.language ? `, ${safe(e.language)}` : ""})` : `${what} ${safe(e.path)}`;
|
|
55
|
+
if (e.ok === false) {
|
|
56
|
+
const reason = REASONS[e.reason] || safe(e.message || e.reason || "failed");
|
|
57
|
+
return [`REFUSED ${target}: ${reason}`];
|
|
58
|
+
}
|
|
59
|
+
if (e.tool === "analyze_directory") {
|
|
60
|
+
const extra = [];
|
|
61
|
+
if (Number(e.excluded) > 0) extra.push(`${Number(e.excluded)} excluded`);
|
|
62
|
+
if (Number(e.notRead) > 0) extra.push(`${Number(e.notRead)} not read (too large or unreadable)`);
|
|
63
|
+
if (e.truncated) extra.push("stopped at the file limit");
|
|
64
|
+
if (e.timedOut) extra.push("stopped at the time limit");
|
|
65
|
+
if (Number(e.hashed) > 0) extra.push(`sha256 of ${Number(e.hashed)} file(s) in log --json`);
|
|
66
|
+
const lines = [`${target}: ${Number(e.analyzed) || 0} file(s)${riskCounts(e.riskCounts)}${extra.length ? `, ${extra.join(", ")}` : ""}`];
|
|
67
|
+
if (Array.isArray(e.top)) for (const f of e.top) if (f) lines.push(` ${safe(f.risk).padEnd(8)} ${safe(f.path)}${shortHash(f.sha256)}`);
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
const incomplete = e.analysisStatus && e.analysisStatus !== "complete" ? " (incomplete: broken code)" : "";
|
|
71
|
+
return [`${target}: ${safe(e.risk)}${incomplete}${shortHash(e.sha256)}`];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Hash lines of a folder call are data for --json, not entries to read.
|
|
75
|
+
function parseFile(file, { hashLines = true } = {}) {
|
|
76
|
+
const entries = [];
|
|
77
|
+
let damaged = 0;
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.lstatSync(file).isFile()) return { entries, damaged };
|
|
81
|
+
text = fs.readFileSync(file, "utf8");
|
|
82
|
+
} catch { return { entries, damaged }; }
|
|
83
|
+
for (const line of text.split("\n")) {
|
|
84
|
+
if (line === "") continue;
|
|
85
|
+
try {
|
|
86
|
+
const e = JSON.parse(line);
|
|
87
|
+
if (e && typeof e === "object" && !Array.isArray(e) && (typeof e.tool === "string" || typeof e.event === "string")) {
|
|
88
|
+
if (hashLines || e.event !== "hashes") entries.push(e);
|
|
89
|
+
}
|
|
90
|
+
else damaged++;
|
|
91
|
+
} catch { damaged++; }
|
|
92
|
+
}
|
|
93
|
+
return { entries, damaged };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printEntry(io, e) {
|
|
97
|
+
const client = e.client ? ` [${safe(e.client).slice(0, 40)}]` : "";
|
|
98
|
+
const [first, ...rest] = describe(e);
|
|
99
|
+
io.stdout.write(`${localTime(e.ts)}${client} ${first}\n`);
|
|
100
|
+
for (const line of rest) io.stdout.write(line + "\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Files are read one at a time (a day can hold 50 MB), newest first for the
|
|
104
|
+
// default view, which stops once it has enough entries.
|
|
105
|
+
function runLog(opts, io) {
|
|
106
|
+
let dir;
|
|
107
|
+
try {
|
|
108
|
+
dir = opts.dir ? normalizeAuditDir(path.resolve(io.cwd, opts.dir)) : defaultAuditDir(io.env, io.platform);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
111
|
+
io.stderr.write(`sentinel-ir-mcp log: ${e.message}\n`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const files = logFiles(dir).map((n) => path.join(dir, n));
|
|
115
|
+
let damaged = 0;
|
|
116
|
+
let count = 0;
|
|
117
|
+
let more = false;
|
|
118
|
+
let first = true;
|
|
119
|
+
const emit = opts.json
|
|
120
|
+
? (e) => { io.stdout.write(`${first ? "" : ","}\n ${JSON.stringify(e)}`); first = false; }
|
|
121
|
+
: (e) => printEntry(io, e);
|
|
122
|
+
if (opts.json) io.stdout.write(`{\n "folder": ${JSON.stringify(dir)},\n "entries": [`);
|
|
123
|
+
if (opts.all) {
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
const r = parseFile(file, { hashLines: Boolean(opts.json) });
|
|
126
|
+
damaged += r.damaged;
|
|
127
|
+
for (const e of r.entries) emit(e);
|
|
128
|
+
count += r.entries.length;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
let recent = [];
|
|
132
|
+
for (let i = files.length - 1; i >= 0 && recent.length < RECENT; i--) {
|
|
133
|
+
const r = parseFile(files[i], { hashLines: Boolean(opts.json) });
|
|
134
|
+
damaged += r.damaged;
|
|
135
|
+
recent = r.entries.concat(recent);
|
|
136
|
+
if (recent.length >= RECENT && i > 0) more = true;
|
|
137
|
+
}
|
|
138
|
+
if (recent.length > RECENT) { more = true; recent = recent.slice(-RECENT); }
|
|
139
|
+
for (const e of recent) emit(e);
|
|
140
|
+
count = recent.length;
|
|
141
|
+
}
|
|
142
|
+
if (opts.json) {
|
|
143
|
+
io.stdout.write(`${first ? "" : "\n "}],\n "damaged": ${damaged}\n}\n`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
if (count === 0) {
|
|
147
|
+
io.stdout.write(`No audit log in ${dir}.\n` +
|
|
148
|
+
`Turn it on with: npx -y ${PACKAGE_SPEC} setup --audit-log\n` +
|
|
149
|
+
`Then restart the AI app; entries appear when the agent uses the tools.\n`);
|
|
150
|
+
} else {
|
|
151
|
+
const summary = more ? `Showing the last ${count} entries; --all shows everything.` : `${count} entries.`;
|
|
152
|
+
io.stdout.write(`\n${summary} Log folder: ${dir} (kept ${KEEP_DAYS} days)\n`);
|
|
153
|
+
}
|
|
154
|
+
if (damaged) io.stdout.write(`${damaged} damaged line(s) skipped.\n`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { runLog, safe, describe };
|
package/src/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const { version } = require("../package.json");
|
|
|
6
6
|
const { AccessError, canonicalRoots, rootsFromUris } = require("./paths");
|
|
7
7
|
const { analyzeCode, analyzeFile, analyzeDirectory } = require("./analyze");
|
|
8
8
|
const { createExcluder } = require("./exclude");
|
|
9
|
+
const { createAuditLog, describeCall, splitHashes } = require("./auditLog");
|
|
9
10
|
|
|
10
11
|
const DEFAULT_LIMITS = Object.freeze({
|
|
11
12
|
maxFileBytes: 1024 * 1024,
|
|
@@ -61,12 +62,27 @@ function fail(message) {
|
|
|
61
62
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
function refusalReason(err) {
|
|
66
|
+
if (!(err instanceof AccessError)) return "error";
|
|
67
|
+
const m = err.message;
|
|
68
|
+
if (m.startsWith("Excluded by the user")) return "excluded";
|
|
69
|
+
if (m.startsWith("File too large")) return "too-large";
|
|
70
|
+
if (m.includes("outside the allowed folders")) return "outside";
|
|
71
|
+
if (m.startsWith("No allowed folders")) return "no-folders";
|
|
72
|
+
if (m.startsWith("Not found")) return "not-found";
|
|
73
|
+
return "refused";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function guarded(fn, audit) {
|
|
77
|
+
const started = Date.now();
|
|
65
78
|
try {
|
|
66
|
-
|
|
79
|
+
const result = await fn();
|
|
80
|
+
audit({ result }, Date.now() - started);
|
|
81
|
+
return ok(result);
|
|
67
82
|
} catch (err) {
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
const message = err instanceof AccessError ? err.message : `Analysis failed: ${err && err.message ? err.message : String(err)}`;
|
|
84
|
+
audit({ error: message, reason: refusalReason(err) }, Date.now() - started);
|
|
85
|
+
return fail(message);
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
88
|
|
|
@@ -83,8 +99,23 @@ function createServer(options = {}) {
|
|
|
83
99
|
timeBudgetMs: Math.min(options.timeBudgetMs ?? DEFAULT_LIMITS.timeBudgetMs, DEFAULT_LIMITS.hardMaxTimeBudgetMs)
|
|
84
100
|
};
|
|
85
101
|
const fixedRoots = canonicalRoots(options.roots || []);
|
|
102
|
+
const auditLog = options.auditLog || createAuditLog({ dir: options.auditDir });
|
|
86
103
|
|
|
87
104
|
const server = new McpServer({ name: "sentinel-ir-mcp", version }, { instructions: INSTRUCTIONS + excludeNote });
|
|
105
|
+
if (auditLog.active) auditLog.record({ event: "start", version, folders: fixedRoots, exclusions: excluder.patterns.length });
|
|
106
|
+
|
|
107
|
+
function auditor(tool, args) {
|
|
108
|
+
if (!auditLog.active) return () => {};
|
|
109
|
+
return (outcome, ms) => {
|
|
110
|
+
try {
|
|
111
|
+
const info = server.server.getClientVersion();
|
|
112
|
+
const client = info && typeof info.name === "string" ? info.name.slice(0, 100) : undefined;
|
|
113
|
+
const [main, ...hashLines] = splitHashes({ client, ...describeCall(tool, args, outcome), ms });
|
|
114
|
+
auditLog.record(main);
|
|
115
|
+
for (const line of hashLines) auditLog.record(line);
|
|
116
|
+
} catch { /* logging must never break a tool call */ }
|
|
117
|
+
};
|
|
118
|
+
}
|
|
88
119
|
|
|
89
120
|
// Folders given on the command line win. Otherwise ask the client for its
|
|
90
121
|
// MCP roots on every call (they can change while the session runs).
|
|
@@ -112,7 +143,7 @@ function createServer(options = {}) {
|
|
|
112
143
|
language: z.enum(["javascript", "typescript", "tsx", "python"]).optional().describe("Overrides the language picked from target (default javascript)")
|
|
113
144
|
},
|
|
114
145
|
annotations: { title: "Analyze JavaScript, TypeScript or Python source", ...READ_ONLY }
|
|
115
|
-
}, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language)));
|
|
146
|
+
}, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language), auditor("analyze_code", { code, target, language })));
|
|
116
147
|
|
|
117
148
|
server.registerTool("analyze_file", {
|
|
118
149
|
title: "Analyze a JavaScript, TypeScript or Python file",
|
|
@@ -124,7 +155,10 @@ function createServer(options = {}) {
|
|
|
124
155
|
path: z.string().min(1).describe("File path, absolute or relative to the first allowed folder")
|
|
125
156
|
},
|
|
126
157
|
annotations: { title: "Analyze a JavaScript, TypeScript or Python file", ...READ_ONLY }
|
|
127
|
-
}, async ({ path }) =>
|
|
158
|
+
}, async ({ path }) => {
|
|
159
|
+
const hashes = auditLog.active ? new Map() : null;
|
|
160
|
+
return guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder, hashes }), auditor("analyze_file", { path, hashes }));
|
|
161
|
+
});
|
|
128
162
|
|
|
129
163
|
server.registerTool("analyze_directory", {
|
|
130
164
|
title: "Analyze a folder of JavaScript, TypeScript and Python files",
|
|
@@ -153,16 +187,20 @@ function createServer(options = {}) {
|
|
|
153
187
|
.describe("\"summary\" (default): short entries; \"full\": all facts per file, much larger")
|
|
154
188
|
},
|
|
155
189
|
annotations: { title: "Analyze a folder of JavaScript, TypeScript and Python files", ...READ_ONLY }
|
|
156
|
-
}, async ({ path, max_files, recursive, detail }) =>
|
|
157
|
-
|
|
190
|
+
}, async ({ path, max_files, recursive, detail }) => {
|
|
191
|
+
const hashes = auditLog.active ? new Map() : null;
|
|
192
|
+
return guarded(async () => analyzeDirectory(path ?? ".", await allowedRoots(), {
|
|
158
193
|
maxFiles: max_files ?? limits.maxFiles,
|
|
159
194
|
maxFileBytes: limits.maxFileBytes,
|
|
160
195
|
timeBudgetMs: limits.timeBudgetMs,
|
|
161
196
|
recursive: recursive ?? true,
|
|
162
197
|
detail: detail ?? "summary",
|
|
163
|
-
excluder
|
|
164
|
-
|
|
198
|
+
excluder,
|
|
199
|
+
hashes
|
|
200
|
+
}), auditor("analyze_directory", { path, max_files, recursive, detail, hashes }));
|
|
201
|
+
});
|
|
165
202
|
|
|
203
|
+
server.auditLog = auditLog;
|
|
166
204
|
return server;
|
|
167
205
|
}
|
|
168
206
|
|
package/src/setup.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { isDeepStrictEqual } = require("util");
|
|
6
|
-
const { SERVER_NAME, PACKAGE_SPEC, CLIENTS, homeOf, serverEntry, installCommand, shellQuote, powershellQuote, entryExcludes } = require("./clients");
|
|
6
|
+
const { SERVER_NAME, PACKAGE_SPEC, CLIENTS, homeOf, serverEntry, installCommand, shellQuote, powershellQuote, entryExcludes, entryAuditLog } = require("./clients");
|
|
7
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects } = require("./auditLog");
|
|
7
8
|
const { createExcluder, ExcludeError } = require("./exclude");
|
|
8
9
|
const { describeExcluded } = require("./excludeReport");
|
|
9
10
|
const { SKIP_DIRS } = require("./analyze");
|
|
@@ -138,6 +139,48 @@ async function chooseExcludes(opts, io, previous) {
|
|
|
138
139
|
return toPatterns(answer.split(",").map((s) => s.trim()).filter(Boolean));
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// --no-audit-log turns it off, --audit-log[=<folder>] on, otherwise the current
|
|
143
|
+
// setting is kept; an interactive run asks, with Enter keeping it.
|
|
144
|
+
async function chooseAuditLog(opts, io, previous, project) {
|
|
145
|
+
if (opts.noAuditLog && opts.auditLog) throw new SetupError("Use either --audit-log or --no-audit-log, not both.");
|
|
146
|
+
let audit;
|
|
147
|
+
if (opts.noAuditLog) audit = null;
|
|
148
|
+
else if (opts.auditLog === true) audit = { dir: null };
|
|
149
|
+
else if (typeof opts.auditLog === "string") audit = { dir: opts.auditLog };
|
|
150
|
+
else {
|
|
151
|
+
const current = previous && Array.isArray(previous.args) ? entryAuditLog(previous.args) : null;
|
|
152
|
+
audit = current;
|
|
153
|
+
if (!(opts.yes || opts.dryRun || !io.isTTY || !io.prompt)) {
|
|
154
|
+
const question = current
|
|
155
|
+
? "Keep a log of what the agent asks for (file names and results, never your code)? (now: on; Enter = keep, n = off) "
|
|
156
|
+
: "Keep a log of what the agent asks for (file names and results, never your code)? [y/N] ";
|
|
157
|
+
const answer = (await io.prompt(question)).trim().toLowerCase();
|
|
158
|
+
if (answer === "y" || answer === "yes") audit = current || { dir: null };
|
|
159
|
+
else if (answer === "n" || answer === "no") audit = null;
|
|
160
|
+
else if (answer !== "") throw new SetupError(`Answer y or n, not "${answer}". Nothing was changed.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!audit) return null;
|
|
164
|
+
try {
|
|
165
|
+
const dir = audit.dir ? normalizeAuditDir(audit.dir) : null;
|
|
166
|
+
assertOutsideProjects(dir || defaultAuditDir(io.env, io.platform), [project], io.platform === "win32" || io.platform === "darwin");
|
|
167
|
+
return { dir };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof AuditLogError) throw new SetupError(`${e.message}. Nothing was changed.`);
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function describeAudit(audit, io) {
|
|
175
|
+
if (!audit) return `Audit log: off (turn it on with: npx -y ${PACKAGE_SPEC} setup --audit-log)
|
|
176
|
+
`;
|
|
177
|
+
const dir = audit.dir || defaultAuditDir(io.env, io.platform);
|
|
178
|
+
return `Audit log: on, in ${dir} (only what the agent asked for and the results, never your code)
|
|
179
|
+
` +
|
|
180
|
+
` see it: npx -y ${PACKAGE_SPEC} log${audit.dir ? ` --dir ${hintArg(audit.dir, io.platform)}` : ""}
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
function describePlan(client, plan, project) {
|
|
142
185
|
const lines = [
|
|
143
186
|
`Client: ${CLIENTS[client].label}`,
|
|
@@ -186,15 +229,17 @@ async function runSetup(opts, io) {
|
|
|
186
229
|
|
|
187
230
|
if (spec.kind === "command") {
|
|
188
231
|
const exclude = await chooseExcludes(opts, io, undefined);
|
|
232
|
+
const audit = await chooseAuditLog(opts, io, undefined, project);
|
|
189
233
|
if (opts.config) throw new SetupError(`--config does not apply to ${spec.label}; it installs servers with its own command.`);
|
|
190
234
|
if (io.platform === "win32") {
|
|
191
|
-
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run in PowerShell:\n\n ${installCommand(client, project, "win32", "powershell", exclude)}\n\n` +
|
|
192
|
-
`or in Command Prompt (cmd.exe):\n\n ${installCommand(client, project, "win32", "cmd", exclude)}\n\n`);
|
|
235
|
+
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run in PowerShell:\n\n ${installCommand(client, project, "win32", "powershell", exclude, audit)}\n\n` +
|
|
236
|
+
`or in Command Prompt (cmd.exe):\n\n ${installCommand(client, project, "win32", "cmd", exclude, audit)}\n\n`);
|
|
193
237
|
} else {
|
|
194
|
-
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run:\n\n ${installCommand(client, project, io.platform, undefined, exclude)}\n\n`);
|
|
238
|
+
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run:\n\n ${installCommand(client, project, io.platform, undefined, exclude, audit)}\n\n`);
|
|
195
239
|
}
|
|
196
240
|
out(`Access: ${project} (only this folder, read-only)\n`);
|
|
197
241
|
out(describeExcluded(exclude, [project], SKIP_DIRS).join("\n") + "\n");
|
|
242
|
+
out(describeAudit(audit, io));
|
|
198
243
|
out(`Then check the connection${io.platform === "win32" ? " (PowerShell)" : ""}: npx -y ${PACKAGE_SPEC} doctor --client ${client} --project ${hintArg(project, io.platform)}\n`);
|
|
199
244
|
out(percentNote(project));
|
|
200
245
|
return 0;
|
|
@@ -209,9 +254,11 @@ async function runSetup(opts, io) {
|
|
|
209
254
|
const seed = !opts.config && spec.legacyConfigPath ? spec.legacyConfigPath(ctx) : null;
|
|
210
255
|
const { previous } = planFileChange(file, serverEntry(folder, io.platform), seed);
|
|
211
256
|
const exclude = await chooseExcludes(opts, io, previous);
|
|
212
|
-
const
|
|
257
|
+
const audit = await chooseAuditLog(opts, io, previous, project);
|
|
258
|
+
const plan = planFileChange(file, serverEntry(folder, io.platform, exclude, audit), seed);
|
|
213
259
|
out(describePlan(client, plan, project));
|
|
214
260
|
out(describeExcluded(exclude, [project], SKIP_DIRS).join("\n") + "\n");
|
|
261
|
+
out(describeAudit(audit, io));
|
|
215
262
|
out(percentNote(project));
|
|
216
263
|
|
|
217
264
|
if (plan.action === "unchanged") {
|