sentinel-ir-mcp 0.4.4 → 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 +14 -0
- package/package.json +1 -1
- package/src/analyze.js +10 -5
- package/src/auditLog.js +28 -2
- package/src/logView.js +15 -6
- package/src/server.js +15 -7
package/README.md
CHANGED
|
@@ -25,6 +25,10 @@ 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
|
+
|
|
28
32
|
**0.4.4** can keep a log of what the agent asked for (off unless you turn it on):
|
|
29
33
|
|
|
30
34
|
- **One question in `setup`:** *"Keep a log of what the agent asks for (file
|
|
@@ -358,6 +362,16 @@ server through `cmd`). Then read it with `log --dir <folder>`.
|
|
|
358
362
|
result (risk, `analysisStatus`, file counts, up to 20 `critical`/`high` file
|
|
359
363
|
paths of a folder) or why it was refused (`excluded`, `outside`,
|
|
360
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.
|
|
361
375
|
- **What is not written:** source code. For `analyze_code` only the size in
|
|
362
376
|
bytes, the language and the risk. Names of excluded files are not written
|
|
363
377
|
either, only their count. Long values are cut to 300 characters.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinel-ir-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript and Python files instead of the raw source. Read-only, sandboxed to allowed folders.",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
package/src/analyze.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const fs = require("fs/promises");
|
|
4
|
+
const { createHash } = require("crypto");
|
|
4
5
|
const { constants, realpathSync } = require("fs");
|
|
5
6
|
const path = require("path");
|
|
6
7
|
const { analyzeAsync, compress } = require("sentinel-ir");
|
|
@@ -35,7 +36,7 @@ function resolveAllowed(requested, roots, excluder) {
|
|
|
35
36
|
// path may be swapped for a symlink between the check and the read. The open
|
|
36
37
|
// handle must be a regular file whose inode is the one now found at a real
|
|
37
38
|
// path inside the allowed folders, and at most maxFileBytes are read.
|
|
38
|
-
async function readInside(full, roots, maxFileBytes, label, excluder = NONE) {
|
|
39
|
+
async function readInside(full, roots, maxFileBytes, label, excluder = NONE, onRead = null) {
|
|
39
40
|
let handle;
|
|
40
41
|
try {
|
|
41
42
|
handle = await fs.open(full, OPEN_FLAGS);
|
|
@@ -73,6 +74,8 @@ async function readInside(full, roots, maxFileBytes, label, excluder = NONE) {
|
|
|
73
74
|
}
|
|
74
75
|
if (length > maxFileBytes) throw new TooLargeError(`File too large: ${label} grew past the limit of ${maxFileBytes} bytes`);
|
|
75
76
|
if (length === buffer.length) throw new AccessError(`Access denied: ${label} changed while it was being read`);
|
|
77
|
+
// The audit log records which bytes were read, never the bytes.
|
|
78
|
+
if (onRead) onRead(createHash("sha256").update(buffer.subarray(0, length)).digest("hex"));
|
|
76
79
|
return buffer.toString("utf8", 0, length);
|
|
77
80
|
} finally {
|
|
78
81
|
await handle.close();
|
|
@@ -94,7 +97,7 @@ async function analyzeCode(code, target, language) {
|
|
|
94
97
|
return compress(ir);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE }) {
|
|
100
|
+
async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE, hashes = null }) {
|
|
98
101
|
const { real, display } = resolveAllowed(requested, roots, excluder);
|
|
99
102
|
const stat = await fs.stat(real);
|
|
100
103
|
if (!stat.isFile()) throw new AccessError(`Not a file: ${requested}`);
|
|
@@ -104,7 +107,7 @@ async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE })
|
|
|
104
107
|
if (stat.size > maxFileBytes) {
|
|
105
108
|
throw new AccessError(`File too large: ${requested} is ${stat.size} bytes, limit is ${maxFileBytes}`);
|
|
106
109
|
}
|
|
107
|
-
const code = await readInside(real, roots, maxFileBytes, requested, excluder);
|
|
110
|
+
const code = await readInside(real, roots, maxFileBytes, requested, excluder, hashes && ((h) => hashes.set(display, h)));
|
|
108
111
|
return analyzeCode(code, display);
|
|
109
112
|
}
|
|
110
113
|
|
|
@@ -221,7 +224,7 @@ function fullEntry(rel, ir) {
|
|
|
221
224
|
async function analyzeDirectory(
|
|
222
225
|
requested,
|
|
223
226
|
roots,
|
|
224
|
-
{ maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity, detail = "summary", excluder = NONE }
|
|
227
|
+
{ maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity, detail = "summary", excluder = NONE, hashes = null }
|
|
225
228
|
) {
|
|
226
229
|
const started = Date.now();
|
|
227
230
|
const { real, root, display } = resolveAllowed(requested, roots, excluder);
|
|
@@ -241,7 +244,9 @@ async function analyzeDirectory(
|
|
|
241
244
|
// One file that cannot be read or analyzed must not hide the others.
|
|
242
245
|
let ir;
|
|
243
246
|
try {
|
|
244
|
-
|
|
247
|
+
let hash = null;
|
|
248
|
+
ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel, excluder, hashes && ((h) => { hash = h; })), rel);
|
|
249
|
+
if (hash) hashes.set(rel, hash);
|
|
245
250
|
} catch (err) {
|
|
246
251
|
if (err instanceof ExcludedError) {
|
|
247
252
|
skipped.excluded++;
|
package/src/auditLog.js
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
|
-
|
|
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"))
|
|
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
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
186
|
-
|
|
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
|
-
|
|
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;
|