sentinel-ir-mcp 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,25 @@ of safety. Other languages are not supported yet. What is detected in Python
25
25
  (and what is not) is listed in the
26
26
  [sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
27
27
 
28
+ **0.4.4** can keep a log of what the agent asked for (off unless you turn it on):
29
+
30
+ - **One question in `setup`:** *"Keep a log of what the agent asks for (file
31
+ names and results, never your code)? [y/N]"*. Or `setup --audit-log`;
32
+ `--no-audit-log` turns it off. Running `setup` again keeps the setting.
33
+ - **One command to read it:** `npx -y sentinel-ir-mcp@0.4 log` prints the last
34
+ 50 entries as plain lines, `--all` everything, `--json` for scripts:
35
+
36
+ ```text
37
+ 2026-10-18 10:32:05 [cursor] file src/app.js: critical
38
+ 2026-10-18 10:32:09 [cursor] REFUSED file secrets/aws.js: excluded by your settings
39
+ 2026-10-18 10:33:40 [cursor] folder .: 42 file(s) (1 critical, 6 medium, 35 low), 3 excluded
40
+ critical src/app.js
41
+ ```
42
+ - `doctor` says whether the log is on, where it is and how big, and proves the
43
+ log can be written. Details: [Audit log](#audit-log).
44
+
45
+ Also uses `sentinel-ir` 0.4.5 (Python parse errors point where Python does).
46
+
28
47
  **0.4.3** lets you exclude files and folders:
29
48
 
30
49
  - **One question in `setup`:** *"Files or folders the agent must not read"*,
@@ -187,7 +206,7 @@ code 0 when everything passes, 1 otherwise; `--json` gives a machine-readable
187
206
  report.
188
207
 
189
208
  Only `setup` writes files, and only the client config. The MCP server itself
190
- stays read-only.
209
+ reads only; the one exception is the [audit log](#audit-log), if you turn it on.
191
210
 
192
211
  **Cursor:** a server from a project `.cursor/mcp.json` starts switched off.
193
212
  Open Cursor Settings → Customize → MCPs and turn on the switch next to
@@ -317,12 +336,59 @@ npx -y sentinel-ir-mcp@0.4 setup --client cursor --exclude secrets --exclude "*.
317
336
  The exclusions only bind this server. An agent that can read files with
318
337
  other tools of its client is told about them, but this server cannot stop it.
319
338
 
339
+ ### Audit log
340
+
341
+ ```bash
342
+ npx -y sentinel-ir-mcp@0.4 setup --client cursor --audit-log # or answer "y" in setup
343
+ npx -y sentinel-ir-mcp@0.4 log # what the agent asked for
344
+ ```
345
+
346
+ | system | folder |
347
+ |---|---|
348
+ | Windows | `%LOCALAPPDATA%\sentinel-ir-mcp\audit-log\` |
349
+ | macOS | `~/Library/Logs/sentinel-ir-mcp/` |
350
+ | Linux | `~/.local/state/sentinel-ir-mcp/audit-log/` (or `$XDG_STATE_HOME`) |
351
+
352
+ `--audit-log=<folder>` uses another folder: a full path outside the project
353
+ (the characters `" & | < > ^ % !` are refused because Windows starts the
354
+ server through `cmd`). Then read it with `log --dir <folder>`.
355
+
356
+ - **What is written:** one JSON line per tool call in `audit-YYYY-MM-DD.jsonl`
357
+ (local date): time, client name, tool, the path the agent asked for, the
358
+ result (risk, `analysisStatus`, file counts, up to 20 `critical`/`high` file
359
+ paths of a folder) or why it was refused (`excluded`, `outside`,
360
+ `not-found`, `too-large`), and the duration. A line when the server starts.
361
+ - **What is not written:** source code. For `analyze_code` only the size in
362
+ bytes, the language and the risk. Names of excluded files are not written
363
+ either, only their count. Long values are cut to 300 characters.
364
+ - **Cleanup:** day files older than 30 days are deleted; a day stops 1 MB
365
+ below 50 MB with one line saying so, so parallel servers stay under 50 MB
366
+ too. Other files in the folder are never touched.
367
+ - **Set by you only,** like exclusions: the agent and the project cannot turn
368
+ it on, off or move it. The log is never written inside the project.
369
+ - **Safe to read:** a path with a newline cannot add a fake line (JSON
370
+ escapes it), and `log` shows control and bidi characters as `\u{1b}`, so a
371
+ path cannot recolor or rewrite your terminal.
372
+ - **Never breaks the tools:** when the log cannot be written (disk full, no
373
+ permission) the tools answer as usual and the server says so once on stderr;
374
+ `doctor` reports it as `[FAIL]`. Several servers can share the folder: each
375
+ line is one append, so lines do not mix.
376
+ - `doctor` adds one line, `doctor checked that the log can be written`, and
377
+ none of its own tool calls.
378
+ - A day file that is a symlink, junction or hard link is not written to, on
379
+ Windows too, so the log cannot be used to overwrite another file.
380
+ - It records only what the agent asked **this server** for. Other file tools
381
+ of your AI app are not in it.
382
+ - Files are created readable by you only (`0600`, folder `0700`; on Windows
383
+ the folder in `%LOCALAPPDATA%` is private to your account).
384
+
320
385
  ### Options
321
386
 
322
387
  ```text
323
- sentinel-ir-mcp [--exclude <name>]... [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
388
+ sentinel-ir-mcp [--exclude <name>]... [--audit-log[=<folder>]] [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
324
389
 
325
390
  --exclude <name> file or folder the file tools refuse (repeatable, max 100); see Excluding files
391
+ --audit-log[=<folder>] keep a log of the tool calls (never source code); see Audit log
326
392
 
327
393
  --max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB, max 2097152 = 2 MiB)
328
394
  --time-budget-ms <n> analyze_directory starts no new file after this (default 30000, max 600000)
@@ -331,9 +397,10 @@ sentinel-ir-mcp [--exclude <name>]... [--max-file-bytes <n>] [--max-files <n>] [
331
397
 
332
398
  ## What the server can and cannot do
333
399
 
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
400
+ - **Reads only.** The MCP server never writes project files, runs commands or
401
+ opens network connections. It writes only the [audit log](#audit-log) when
402
+ you turn it on (outside the project). The separate `setup` command writes
403
+ only the client config file you confirm; `doctor` starts this package's own server. The only files it opens are `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts`/`.tsx`/`.py`/`.pyi` files up to
337
404
  `--max-file-bytes`.
338
405
  - **Only inside the allowed folders.** Every path is resolved to its real path
339
406
  (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.4",
4
4
  "description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript and Python files instead of the raw source. Read-only, sandboxed to allowed folders.",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/sdk": "^1.32.1",
22
- "sentinel-ir": "^0.4.3",
22
+ "sentinel-ir": "^0.4.5",
23
23
  "zod": "^4.1.0"
24
24
  },
25
25
  "keywords": [
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { homeOf } = require("./clients");
6
+
7
+ // An optional record, for the user, of what the agent asked this server for.
8
+ // One JSON object per line in one file per local day; never any source code.
9
+ // Turned on only by the user (--audit-log on the command line the client
10
+ // starts), never by the agent or by files in the project.
11
+
12
+ const FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
13
+ const KEEP_DAYS = 30;
14
+ const MAX_DAY_BYTES = 50 * 1024 * 1024;
15
+ // A day stops this far below the limit, so lines that parallel servers append
16
+ // in the same instant still end below MAX_DAY_BYTES (a line is at most ~10 kB).
17
+ const DAY_RESERVE = 1024 * 1024;
18
+ const MAX_TEXT = 300;
19
+ const MAX_LISTED = 20;
20
+ // Characters cmd.exe acts on: an MCP entry on Windows runs through "cmd /c".
21
+ const UNSAFE_DIR = /["&|<>^%!\u0000-\u001f]/;
22
+ const OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
23
+
24
+ class AuditLogError extends Error {}
25
+
26
+ function defaultAuditDir(env = process.env, platform = process.platform) {
27
+ if (platform === "win32") {
28
+ const local = env.LOCALAPPDATA || path.join(homeOf(env), "AppData", "Local");
29
+ return path.join(local, "sentinel-ir-mcp", "audit-log");
30
+ }
31
+ if (platform === "darwin") return path.join(homeOf(env), "Library", "Logs", "sentinel-ir-mcp");
32
+ const state = env.XDG_STATE_HOME || path.join(homeOf(env), ".local", "state");
33
+ return path.join(state, "sentinel-ir-mcp", "audit-log");
34
+ }
35
+
36
+ // "--audit-log" (default folder) or "--audit-log=<folder>"; must be absolute
37
+ // because a client starts the server from a folder you do not choose.
38
+ function normalizeAuditDir(raw) {
39
+ if (typeof raw !== "string" || raw.trim() === "") throw new AuditLogError("--audit-log=<folder> needs a folder");
40
+ const dir = raw.trim();
41
+ if (UNSAFE_DIR.test(dir)) throw new AuditLogError(`audit log folder "${raw}" contains a character that is not allowed (" & | < > ^ % !)`);
42
+ if (!path.isAbsolute(dir)) throw new AuditLogError(`audit log folder "${raw}" must be a full path, e.g. ${process.platform === "win32" ? "C:\\logs\\sentinel" : "/home/me/logs"}`);
43
+ return path.resolve(dir);
44
+ }
45
+
46
+ function isInside(child, parent) {
47
+ const rel = path.relative(parent, child);
48
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
49
+ }
50
+
51
+ function realOrResolved(p) {
52
+ // The folder may not exist yet: resolve its nearest existing parent.
53
+ let current = path.resolve(p);
54
+ const rest = [];
55
+ for (;;) {
56
+ try { return path.join(fs.realpathSync(current), ...rest.reverse()); } catch { /* go up */ }
57
+ const parent = path.dirname(current);
58
+ if (parent === current) return path.resolve(p);
59
+ rest.push(path.basename(current));
60
+ current = parent;
61
+ }
62
+ }
63
+
64
+ // A log inside the project could be committed or read by the agent.
65
+ function assertOutsideProjects(dir, projects, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
66
+ const fold = (p) => (caseInsensitive ? p.toLowerCase() : p);
67
+ const real = fold(realOrResolved(dir));
68
+ for (const project of projects) {
69
+ if (isInside(real, fold(realOrResolved(project)))) {
70
+ throw new AuditLogError(`the audit log folder ${dir} is inside the project folder ${project}; pick a folder outside it`);
71
+ }
72
+ }
73
+ }
74
+
75
+ function pad(n) { return String(n).padStart(2, "0"); }
76
+ function dayName(date) { return `audit-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.jsonl`; }
77
+
78
+ function cut(value, max = MAX_TEXT) {
79
+ if (typeof value !== "string") return value;
80
+ if (value.length <= max) return value;
81
+ let end = max;
82
+ const code = value.charCodeAt(end - 1);
83
+ if (code >= 0xd800 && code <= 0xdbff) end--;
84
+ return value.slice(0, end) + "…";
85
+ }
86
+
87
+ function logFiles(dir) {
88
+ let names;
89
+ try { names = fs.readdirSync(dir); } catch { return []; }
90
+ return names.filter((n) => FILE_RE.test(n)).sort();
91
+ }
92
+
93
+ function pruneOld(dir, now) {
94
+ const limit = new Date(now);
95
+ limit.setHours(0, 0, 0, 0);
96
+ limit.setDate(limit.getDate() - KEEP_DAYS);
97
+ for (const name of logFiles(dir)) {
98
+ const [, y, m, d] = FILE_RE.exec(name);
99
+ if (new Date(Number(y), Number(m) - 1, Number(d)) < limit) {
100
+ try {
101
+ if (fs.lstatSync(path.join(dir, name)).isFile()) fs.unlinkSync(path.join(dir, name));
102
+ } catch { /* another server may have removed it */ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // What a tool call is logged as: what was asked and the outcome, no code.
108
+ function describeCall(tool, args, outcome) {
109
+ const entry = { tool };
110
+ if (tool === "analyze_code") {
111
+ entry.bytes = Buffer.byteLength(typeof args.code === "string" ? args.code : "", "utf8");
112
+ if (typeof args.target === "string") entry.target = cut(args.target);
113
+ if (typeof args.language === "string") entry.language = args.language;
114
+ } else {
115
+ entry.path = cut(typeof args.path === "string" ? args.path : ".");
116
+ if (tool === "analyze_directory") {
117
+ for (const [k, v] of [["max_files", args.max_files], ["recursive", args.recursive], ["detail", args.detail]]) if (v !== undefined) entry[k] = v;
118
+ }
119
+ }
120
+ if (outcome.error) {
121
+ entry.ok = false;
122
+ entry.reason = outcome.reason;
123
+ entry.message = cut(outcome.error);
124
+ return entry;
125
+ }
126
+ entry.ok = true;
127
+ const r = outcome.result;
128
+ if (tool === "analyze_directory") {
129
+ entry.analyzed = r.analyzed;
130
+ entry.riskCounts = r.riskCounts;
131
+ entry.incomplete = r.incomplete.length;
132
+ entry.excluded = r.skipped.excluded;
133
+ const notRead = r.skipped.tooLarge.length + r.skipped.unreadable.length + r.skipped.hardlinks.length;
134
+ if (notRead) entry.notRead = notRead;
135
+ if (r.truncated) entry.truncated = true;
136
+ if (r.timedOut) entry.timedOut = true;
137
+ const top = r.files.filter((f) => f.risk === "critical" || f.risk === "high").slice(0, MAX_LISTED);
138
+ if (top.length) entry.top = top.map((f) => ({ path: cut(f.path), risk: f.risk }));
139
+ } else {
140
+ entry.risk = r.world && r.world.risk;
141
+ entry.analysisStatus = r.analysisStatus;
142
+ entry.language = r.language;
143
+ if (tool === "analyze_file" && r.mission && typeof r.mission.target === "string") entry.file = cut(r.mission.target);
144
+ }
145
+ return entry;
146
+ }
147
+
148
+ function createAuditLog({ dir, now = () => Date.now(), onError = (m) => process.stderr.write(`sentinel-ir-mcp: ${m}\n`) } = {}) {
149
+ if (!dir) return { active: false, record() {}, flush: async () => {} };
150
+ let queue = Promise.resolve();
151
+ let prunedFor = null;
152
+ let fullDay = null;
153
+ let failed = false;
154
+
155
+ async function append(line, file) {
156
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
157
+ // Once per server and day, so a server running for weeks still cleans up.
158
+ if (prunedFor !== file) { prunedFor = file; pruneOld(dir, now()); }
159
+ // Windows has no O_NOFOLLOW, so the checks below refuse a day file that
160
+ // is a symlink, junction or hard link on every system before any write.
161
+ const before = await fs.promises.lstat(file, { bigint: true }).catch((e) => {
162
+ if (e.code === "ENOENT") return null;
163
+ throw e;
164
+ });
165
+ if (before && !before.isFile()) throw new Error(`${file} is not a regular file`);
166
+ const handle = await fs.promises.open(file, OPEN_FLAGS, 0o600);
167
+ try {
168
+ const stat = await handle.stat({ bigint: true });
169
+ if (!stat.isFile()) throw new Error(`${file} is not a regular file`);
170
+ if (stat.nlink > 1n) throw new Error(`${file} has a second name (hard link); not writing`);
171
+ const after = await fs.promises.lstat(file, { bigint: true });
172
+ const same = (a) => a.isFile() && a.ino === stat.ino && a.dev === stat.dev;
173
+ if (!same(after) || (before && !same(before))) throw new Error(`${file} was replaced while opening; not writing`);
174
+ if (stat.size + BigInt(Buffer.byteLength(line)) > BigInt(MAX_DAY_BYTES - DAY_RESERVE)) {
175
+ if (fullDay !== file) {
176
+ fullDay = file;
177
+ await handle.write(JSON.stringify({ ts: new Date(now()).toISOString(), event: "full", limitBytes: MAX_DAY_BYTES }) + "\n");
178
+ }
179
+ return;
180
+ }
181
+ // One write per line: with O_APPEND lines of parallel servers do not interleave.
182
+ await handle.write(line);
183
+ } finally {
184
+ await handle.close();
185
+ }
186
+ }
187
+
188
+ function record(entry) {
189
+ const date = new Date(now());
190
+ const line = JSON.stringify({ ts: date.toISOString(), pid: process.pid, ...entry }) + "\n";
191
+ const file = path.join(dir, dayName(date));
192
+ queue = queue.then(() => append(line, file)).then(() => { failed = false; }, (err) => {
193
+ if (!failed) onError(`cannot write the audit log in ${dir}: ${err.message}`);
194
+ failed = true;
195
+ });
196
+ }
197
+
198
+ return { active: true, dir, record, flush: () => queue };
199
+ }
200
+
201
+ module.exports = {
202
+ AuditLogError, KEEP_DAYS, MAX_DAY_BYTES, FILE_RE,
203
+ defaultAuditDir, normalizeAuditDir, assertOutsideProjects, createAuditLog, describeCall, logFiles, dayName, cut
204
+ };
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,149 @@
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
+ function describe(e) {
42
+ if (e.event === "start") {
43
+ const folders = Array.isArray(e.folders) && e.folders.length ? ` for ${e.folders.map(safe).join(", ")}` : "";
44
+ return [`server started (sentinel-ir-mcp ${safe(e.version)})${folders}`];
45
+ }
46
+ if (e.event === "check") return [`doctor checked that the log can be written`];
47
+ if (e.event === "full") return [`the log for this day reached its size limit; nothing more is written today`];
48
+ const what = TOOL_WORDS[e.tool] ? TOOL_WORDS[e.tool] : safe(e.tool);
49
+ const target = e.tool === "analyze_code" ? `${what} (${Number(e.bytes) || 0} bytes${e.language ? `, ${safe(e.language)}` : ""})` : `${what} ${safe(e.path)}`;
50
+ if (e.ok === false) {
51
+ const reason = REASONS[e.reason] || safe(e.message || e.reason || "failed");
52
+ return [`REFUSED ${target}: ${reason}`];
53
+ }
54
+ if (e.tool === "analyze_directory") {
55
+ const extra = [];
56
+ if (Number(e.excluded) > 0) extra.push(`${Number(e.excluded)} excluded`);
57
+ if (Number(e.notRead) > 0) extra.push(`${Number(e.notRead)} not read (too large or unreadable)`);
58
+ if (e.truncated) extra.push("stopped at the file limit");
59
+ if (e.timedOut) extra.push("stopped at the time limit");
60
+ const lines = [`${target}: ${Number(e.analyzed) || 0} file(s)${riskCounts(e.riskCounts)}${extra.length ? `, ${extra.join(", ")}` : ""}`];
61
+ if (Array.isArray(e.top)) for (const f of e.top) lines.push(` ${safe(f.risk).padEnd(8)} ${safe(f.path)}`);
62
+ return lines;
63
+ }
64
+ const incomplete = e.analysisStatus && e.analysisStatus !== "complete" ? " (incomplete: broken code)" : "";
65
+ return [`${target}: ${safe(e.risk)}${incomplete}`];
66
+ }
67
+
68
+ function parseFile(file) {
69
+ const entries = [];
70
+ let damaged = 0;
71
+ let text;
72
+ try {
73
+ if (!fs.lstatSync(file).isFile()) return { entries, damaged };
74
+ text = fs.readFileSync(file, "utf8");
75
+ } catch { return { entries, damaged }; }
76
+ for (const line of text.split("\n")) {
77
+ if (line === "") continue;
78
+ try {
79
+ const e = JSON.parse(line);
80
+ if (e && typeof e === "object" && !Array.isArray(e) && (typeof e.tool === "string" || typeof e.event === "string")) entries.push(e);
81
+ else damaged++;
82
+ } catch { damaged++; }
83
+ }
84
+ return { entries, damaged };
85
+ }
86
+
87
+ function printEntry(io, e) {
88
+ const client = e.client ? ` [${safe(e.client).slice(0, 40)}]` : "";
89
+ const [first, ...rest] = describe(e);
90
+ io.stdout.write(`${localTime(e.ts)}${client} ${first}\n`);
91
+ for (const line of rest) io.stdout.write(line + "\n");
92
+ }
93
+
94
+ // Files are read one at a time (a day can hold 50 MB), newest first for the
95
+ // default view, which stops once it has enough entries.
96
+ function runLog(opts, io) {
97
+ let dir;
98
+ try {
99
+ dir = opts.dir ? normalizeAuditDir(path.resolve(io.cwd, opts.dir)) : defaultAuditDir(io.env, io.platform);
100
+ } catch (e) {
101
+ if (!(e instanceof AuditLogError)) throw e;
102
+ io.stderr.write(`sentinel-ir-mcp log: ${e.message}\n`);
103
+ return 2;
104
+ }
105
+ const files = logFiles(dir).map((n) => path.join(dir, n));
106
+ let damaged = 0;
107
+ let count = 0;
108
+ let more = false;
109
+ let first = true;
110
+ const emit = opts.json
111
+ ? (e) => { io.stdout.write(`${first ? "" : ","}\n ${JSON.stringify(e)}`); first = false; }
112
+ : (e) => printEntry(io, e);
113
+ if (opts.json) io.stdout.write(`{\n "folder": ${JSON.stringify(dir)},\n "entries": [`);
114
+ if (opts.all) {
115
+ for (const file of files) {
116
+ const r = parseFile(file);
117
+ damaged += r.damaged;
118
+ for (const e of r.entries) emit(e);
119
+ count += r.entries.length;
120
+ }
121
+ } else {
122
+ let recent = [];
123
+ for (let i = files.length - 1; i >= 0 && recent.length < RECENT; i--) {
124
+ const r = parseFile(files[i]);
125
+ damaged += r.damaged;
126
+ recent = r.entries.concat(recent);
127
+ if (recent.length >= RECENT && i > 0) more = true;
128
+ }
129
+ if (recent.length > RECENT) { more = true; recent = recent.slice(-RECENT); }
130
+ for (const e of recent) emit(e);
131
+ count = recent.length;
132
+ }
133
+ if (opts.json) {
134
+ io.stdout.write(`${first ? "" : "\n "}],\n "damaged": ${damaged}\n}\n`);
135
+ return 0;
136
+ }
137
+ if (count === 0) {
138
+ io.stdout.write(`No audit log in ${dir}.\n` +
139
+ `Turn it on with: npx -y ${PACKAGE_SPEC} setup --audit-log\n` +
140
+ `Then restart the AI app; entries appear when the agent uses the tools.\n`);
141
+ } else {
142
+ const summary = more ? `Showing the last ${count} entries; --all shows everything.` : `${count} entries.`;
143
+ io.stdout.write(`\n${summary} Log folder: ${dir} (kept ${KEEP_DAYS} days)\n`);
144
+ }
145
+ if (damaged) io.stdout.write(`${damaged} damaged line(s) skipped.\n`);
146
+ return 0;
147
+ }
148
+
149
+ 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 } = 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,21 @@ 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
+ auditLog.record({ client, ...describeCall(tool, args, outcome), ms });
114
+ } catch { /* logging must never break a tool call */ }
115
+ };
116
+ }
88
117
 
89
118
  // Folders given on the command line win. Otherwise ask the client for its
90
119
  // MCP roots on every call (they can change while the session runs).
@@ -112,7 +141,7 @@ function createServer(options = {}) {
112
141
  language: z.enum(["javascript", "typescript", "tsx", "python"]).optional().describe("Overrides the language picked from target (default javascript)")
113
142
  },
114
143
  annotations: { title: "Analyze JavaScript, TypeScript or Python source", ...READ_ONLY }
115
- }, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language)));
144
+ }, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language), auditor("analyze_code", { code, target, language })));
116
145
 
117
146
  server.registerTool("analyze_file", {
118
147
  title: "Analyze a JavaScript, TypeScript or Python file",
@@ -124,7 +153,7 @@ function createServer(options = {}) {
124
153
  path: z.string().min(1).describe("File path, absolute or relative to the first allowed folder")
125
154
  },
126
155
  annotations: { title: "Analyze a JavaScript, TypeScript or Python file", ...READ_ONLY }
127
- }, async ({ path }) => guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder })));
156
+ }, async ({ path }) => guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder }), auditor("analyze_file", { path })));
128
157
 
129
158
  server.registerTool("analyze_directory", {
130
159
  title: "Analyze a folder of JavaScript, TypeScript and Python files",
@@ -161,8 +190,9 @@ function createServer(options = {}) {
161
190
  recursive: recursive ?? true,
162
191
  detail: detail ?? "summary",
163
192
  excluder
164
- })));
193
+ }), auditor("analyze_directory", { path, max_files, recursive, detail })));
165
194
 
195
+ server.auditLog = auditLog;
166
196
  return server;
167
197
  }
168
198
 
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") {