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 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
- stays read-only.
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 opens
335
- network connections (only the separate `setup` command writes, and only the
336
- 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
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,
@@ -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 TypeScript)
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 or ./guide. Without folders the
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 writes, executes or uses the network.
38
- Only "setup" writes, and only the client config file you confirm.`;
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",
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.3",
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
- ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel, excluder), rel);
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++;
@@ -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, "--global": true, "--dry-run": true, "--yes": true, "-y": 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 (spec[arg] === true) {
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, ...excludeArgs(exclude), folder] };
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 " & | < > ^ % ! (normalizePattern refuses them).
117
- function installCommand(client, project, platform = process.platform, shell = "powershell", exclude = []) {
118
- const json = JSON.stringify(vscodeConfig(project, exclude));
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 = excludeArgs(exclude);
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, excludeArgs } = require("./clients");
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
- return { folders: resolved, exclude };
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
- const transport = new StdioClientTransport({ command: process.execPath, args: [BIN, ...excludeArgs(exclude), ...folders], stderr: "pipe" });
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
- await checkHandshake(folders, exclude, add, opts.timeoutMs || 30000);
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
- async function guarded(fn) {
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
- return ok(await fn());
79
+ const result = await fn();
80
+ audit({ result }, Date.now() - started);
81
+ return ok(result);
67
82
  } catch (err) {
68
- if (err instanceof AccessError) return fail(err.message);
69
- return fail(`Analysis failed: ${err && err.message ? err.message : String(err)}`);
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 }) => guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder })));
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 }) => guarded(async () =>
157
- analyzeDirectory(path ?? ".", await allowedRoots(), {
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 plan = planFileChange(file, serverEntry(folder, io.platform, exclude), seed);
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") {