sentinel-ir-mcp 0.4.4 → 0.4.6

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
@@ -6,8 +6,10 @@ it lets AI agents in Claude Desktop, Cursor, Windsurf or any other
6
6
  compact **structural IR** of JavaScript, TypeScript and Python files instead of the raw source.
7
7
 
8
8
  - npm: <https://www.npmjs.com/package/sentinel-ir-mcp>
9
- - GitLab: <https://gitlab.com/jakubrojicek11/sentinel-ir-mcp>
10
- - Library behind it: <https://gitlab.com/jakubrojicek11/sentinel-ir>
9
+ - GitLab (main repository): <https://gitlab.com/jakubrojicek11/sentinel-ir-mcp>
10
+ - GitHub (mirror): <https://github.com/jakcmencz/sentinel-ir-mcp>
11
+ - Library behind it: [`sentinel-ir`](https://github.com/jakcmencz/sentinel-ir)
12
+ - See what the agent sees, in your browser: <https://sentinel-ir-playground-dc30c8.gitlab.io/>
11
13
 
12
14
  The IR tells the agent what a file *does* (risk level, dangerous
13
15
  capabilities such as `eval` or `child_process`, routes, exports, imports,
@@ -25,6 +27,15 @@ of safety. Other languages are not supported yet. What is detected in Python
25
27
  (and what is not) is listed in the
26
28
  [sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
27
29
 
30
+ **0.4.6** is listed in the official
31
+ [MCP Registry](https://registry.modelcontextprotocol.io) as
32
+ `io.github.jakcmencz/sentinel-ir-mcp`, so clients that browse the registry
33
+ can find and install it. Nothing else changes.
34
+
35
+ **0.4.5** adds to that log the SHA-256 of every file the server read, so you
36
+ can check later which version of a file the agent was shown (see
37
+ [Audit log](#audit-log)).
38
+
28
39
  **0.4.4** can keep a log of what the agent asked for (off unless you turn it on):
29
40
 
30
41
  - **One question in `setup`:** *"Keep a log of what the agent asks for (file
@@ -358,6 +369,16 @@ server through `cmd`). Then read it with `log --dir <folder>`.
358
369
  result (risk, `analysisStatus`, file counts, up to 20 `critical`/`high` file
359
370
  paths of a folder) or why it was refused (`excluded`, `outside`,
360
371
  `not-found`, `too-large`), and the duration. A line when the server starts.
372
+ - **Which version of a file (0.4.5):** for every file the server read, the
373
+ SHA-256 of the exact bytes it analyzed: `sha256` for `analyze_file`; for
374
+ `analyze_directory` the folder line gets a `call` id and `hashed` count,
375
+ and the hashes follow in `"event": "hashes"` lines (40 files each, same
376
+ `call` id), so no line gets long. `log` shows the first 12 digits for
377
+ files and the risky files of a folder; `log --json` has all of them. To check whether a file is still the
378
+ one the agent saw, compare with `certutil -hashfile <file> SHA256`
379
+ (Windows), `shasum -a 256 <file>` (macOS) or `sha256sum <file>` (Linux).
380
+ Pasted code (`analyze_code`) gets no hash: a very short snippet could be
381
+ guessed from it. The tool answers to the agent are unchanged.
361
382
  - **What is not written:** source code. For `analyze_code` only the size in
362
383
  bytes, the language and the risk. Names of excluded files are not written
363
384
  either, only their count. Long values are cut to 300 characters.
@@ -489,8 +510,20 @@ starting the published package through `npx`, and the printed VS Code/Claude
489
510
  Code commands pasted into PowerShell 5.1, PowerShell 7 and cmd.exe. See
490
511
  [COMPATIBILITY.md](COMPATIBILITY.md).
491
512
 
492
- Release: bump `version` in `package.json`, push a protected tag `vX.Y.Z`;
493
- GitLab CI tests and publishes to npm with provenance.
513
+ Release: bump `version` in `package.json` and both `version` fields in
514
+ `server.json` (a test checks they match), push a protected tag `vX.Y.Z`;
515
+ GitLab CI tests and publishes to npm with provenance. Then publish the same
516
+ `server.json` to the MCP Registry: `mcp-publisher login github` and
517
+ `mcp-publisher publish`.
518
+
519
+ ## Contributing
520
+
521
+ Development, CI and npm releases run on
522
+ [GitLab](https://gitlab.com/jakubrojicek11/sentinel-ir-mcp). The
523
+ [GitHub repository](https://github.com/jakcmencz/sentinel-ir-mcp) is a mirror
524
+ that GitLab updates on every push to `main` and every tag. Issues are welcome
525
+ on either; a pull request opened on GitHub is applied on GitLab, and the mirror
526
+ then brings it back.
494
527
 
495
528
  ## License
496
529
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "sentinel-ir-mcp",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
+ "mcpName": "io.github.jakcmencz/sentinel-ir-mcp",
4
5
  "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
6
  "main": "src/server.js",
6
7
  "bin": {
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++;
package/src/auditLog.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("crypto");
3
4
  const fs = require("fs");
4
5
  const path = require("path");
5
6
  const { homeOf } = require("./clients");
@@ -17,6 +18,7 @@ const MAX_DAY_BYTES = 50 * 1024 * 1024;
17
18
  const DAY_RESERVE = 1024 * 1024;
18
19
  const MAX_TEXT = 300;
19
20
  const MAX_LISTED = 20;
21
+ const HASHES_PER_LINE = 40;
20
22
  // Characters cmd.exe acts on: an MCP entry on Windows runs through "cmd /c".
21
23
  const UNSAFE_DIR = /["&|<>^%!\u0000-\u001f]/;
22
24
  const OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
@@ -134,17 +136,41 @@ function describeCall(tool, args, outcome) {
134
136
  if (notRead) entry.notRead = notRead;
135
137
  if (r.truncated) entry.truncated = true;
136
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) } : {});
137
144
  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 }));
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;
139
148
  } else {
140
149
  entry.risk = r.world && r.world.risk;
141
150
  entry.analysisStatus = r.analysisStatus;
142
151
  entry.language = r.language;
143
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];
144
154
  }
145
155
  return entry;
146
156
  }
147
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
+
148
174
  function createAuditLog({ dir, now = () => Date.now(), onError = (m) => process.stderr.write(`sentinel-ir-mcp: ${m}\n`) } = {}) {
149
175
  if (!dir) return { active: false, record() {}, flush: async () => {} };
150
176
  let queue = Promise.resolve();
@@ -200,5 +226,5 @@ function createAuditLog({ dir, now = () => Date.now(), onError = (m) => process.
200
226
 
201
227
  module.exports = {
202
228
  AuditLogError, KEEP_DAYS, MAX_DAY_BYTES, FILE_RE,
203
- defaultAuditDir, normalizeAuditDir, assertOutsideProjects, createAuditLog, describeCall, logFiles, dayName, cut
229
+ defaultAuditDir, normalizeAuditDir, assertOutsideProjects, createAuditLog, describeCall, splitHashes, HASHES_PER_LINE, logFiles, dayName, cut
204
230
  };
package/src/logView.js CHANGED
@@ -38,6 +38,10 @@ function riskCounts(counts) {
38
38
  return parts.length ? ` (${parts.join(", ")})` : "";
39
39
  }
40
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
+
41
45
  function describe(e) {
42
46
  if (e.event === "start") {
43
47
  const folders = Array.isArray(e.folders) && e.folders.length ? ` for ${e.folders.map(safe).join(", ")}` : "";
@@ -45,6 +49,7 @@ function describe(e) {
45
49
  }
46
50
  if (e.event === "check") return [`doctor checked that the log can be written`];
47
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)`];
48
53
  const what = TOOL_WORDS[e.tool] ? TOOL_WORDS[e.tool] : safe(e.tool);
49
54
  const target = e.tool === "analyze_code" ? `${what} (${Number(e.bytes) || 0} bytes${e.language ? `, ${safe(e.language)}` : ""})` : `${what} ${safe(e.path)}`;
50
55
  if (e.ok === false) {
@@ -57,15 +62,17 @@ function describe(e) {
57
62
  if (Number(e.notRead) > 0) extra.push(`${Number(e.notRead)} not read (too large or unreadable)`);
58
63
  if (e.truncated) extra.push("stopped at the file limit");
59
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`);
60
66
  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)}`);
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)}`);
62
68
  return lines;
63
69
  }
64
70
  const incomplete = e.analysisStatus && e.analysisStatus !== "complete" ? " (incomplete: broken code)" : "";
65
- return [`${target}: ${safe(e.risk)}${incomplete}`];
71
+ return [`${target}: ${safe(e.risk)}${incomplete}${shortHash(e.sha256)}`];
66
72
  }
67
73
 
68
- function parseFile(file) {
74
+ // Hash lines of a folder call are data for --json, not entries to read.
75
+ function parseFile(file, { hashLines = true } = {}) {
69
76
  const entries = [];
70
77
  let damaged = 0;
71
78
  let text;
@@ -77,7 +84,9 @@ function parseFile(file) {
77
84
  if (line === "") continue;
78
85
  try {
79
86
  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);
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
+ }
81
90
  else damaged++;
82
91
  } catch { damaged++; }
83
92
  }
@@ -113,7 +122,7 @@ function runLog(opts, io) {
113
122
  if (opts.json) io.stdout.write(`{\n "folder": ${JSON.stringify(dir)},\n "entries": [`);
114
123
  if (opts.all) {
115
124
  for (const file of files) {
116
- const r = parseFile(file);
125
+ const r = parseFile(file, { hashLines: Boolean(opts.json) });
117
126
  damaged += r.damaged;
118
127
  for (const e of r.entries) emit(e);
119
128
  count += r.entries.length;
@@ -121,7 +130,7 @@ function runLog(opts, io) {
121
130
  } else {
122
131
  let recent = [];
123
132
  for (let i = files.length - 1; i >= 0 && recent.length < RECENT; i--) {
124
- const r = parseFile(files[i]);
133
+ const r = parseFile(files[i], { hashLines: Boolean(opts.json) });
125
134
  damaged += r.damaged;
126
135
  recent = r.entries.concat(recent);
127
136
  if (recent.length >= RECENT && i > 0) more = true;
package/src/server.js CHANGED
@@ -6,7 +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
+ const { createAuditLog, describeCall, splitHashes } = require("./auditLog");
10
10
 
11
11
  const DEFAULT_LIMITS = Object.freeze({
12
12
  maxFileBytes: 1024 * 1024,
@@ -110,7 +110,9 @@ function createServer(options = {}) {
110
110
  try {
111
111
  const info = server.server.getClientVersion();
112
112
  const client = info && typeof info.name === "string" ? info.name.slice(0, 100) : undefined;
113
- auditLog.record({ client, ...describeCall(tool, args, outcome), ms });
113
+ const [main, ...hashLines] = splitHashes({ client, ...describeCall(tool, args, outcome), ms });
114
+ auditLog.record(main);
115
+ for (const line of hashLines) auditLog.record(line);
114
116
  } catch { /* logging must never break a tool call */ }
115
117
  };
116
118
  }
@@ -153,7 +155,10 @@ function createServer(options = {}) {
153
155
  path: z.string().min(1).describe("File path, absolute or relative to the first allowed folder")
154
156
  },
155
157
  annotations: { title: "Analyze a JavaScript, TypeScript or Python file", ...READ_ONLY }
156
- }, async ({ path }) => guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder }), auditor("analyze_file", { path })));
158
+ }, async ({ path }) => {
159
+ const hashes = auditLog.active ? new Map() : null;
160
+ return guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder, hashes }), auditor("analyze_file", { path, hashes }));
161
+ });
157
162
 
158
163
  server.registerTool("analyze_directory", {
159
164
  title: "Analyze a folder of JavaScript, TypeScript and Python files",
@@ -182,15 +187,18 @@ function createServer(options = {}) {
182
187
  .describe("\"summary\" (default): short entries; \"full\": all facts per file, much larger")
183
188
  },
184
189
  annotations: { title: "Analyze a folder of JavaScript, TypeScript and Python files", ...READ_ONLY }
185
- }, async ({ path, max_files, recursive, detail }) => guarded(async () =>
186
- 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(), {
187
193
  maxFiles: max_files ?? limits.maxFiles,
188
194
  maxFileBytes: limits.maxFileBytes,
189
195
  timeBudgetMs: limits.timeBudgetMs,
190
196
  recursive: recursive ?? true,
191
197
  detail: detail ?? "summary",
192
- excluder
193
- }), auditor("analyze_directory", { path, max_files, recursive, detail })));
198
+ excluder,
199
+ hashes
200
+ }), auditor("analyze_directory", { path, max_files, recursive, detail, hashes }));
201
+ });
194
202
 
195
203
  server.auditLog = auditLog;
196
204
  return server;