toolmoat 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framing for the MCP stdio transport: newline-delimited JSON-RPC 2.0
|
|
3
|
+
* messages. Lines are split on raw bytes, so chunks may break a line or a
|
|
4
|
+
* multi-byte character anywhere, and lines may end with \n or \r\n.
|
|
5
|
+
*/
|
|
6
|
+
export interface Line {
|
|
7
|
+
/** The line's bytes without the terminating \n (a trailing \r is kept, so the line can be forwarded byte for byte). */
|
|
8
|
+
raw: Buffer;
|
|
9
|
+
/** The line decoded as UTF-8, without its \n or \r\n terminator. */
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
/** Splits a byte stream into lines without quadratic copying, however the stream is chunked. */
|
|
13
|
+
export declare class LineDecoder {
|
|
14
|
+
private pending;
|
|
15
|
+
/** Feeds one chunk and returns every line it completed. */
|
|
16
|
+
push(chunk: Buffer): Line[];
|
|
17
|
+
/** Returns the unterminated last line at end of stream, if there is one. */
|
|
18
|
+
flush(): Line | undefined;
|
|
19
|
+
}
|
|
20
|
+
export type JsonRpcId = string | number;
|
|
21
|
+
/** A JSON-RPC message as parsed from the wire. Fields are unvalidated: the peer may be hostile. */
|
|
22
|
+
export type JsonRpcMessage = Record<string, unknown>;
|
|
23
|
+
export type ParsedLine = {
|
|
24
|
+
kind: 'message';
|
|
25
|
+
message: JsonRpcMessage;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'batch';
|
|
28
|
+
items: unknown[];
|
|
29
|
+
}
|
|
30
|
+
/** Starts like JSON but does not parse. A lenient reader at the other end might still accept it. */
|
|
31
|
+
| {
|
|
32
|
+
kind: 'invalid';
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'raw';
|
|
35
|
+
};
|
|
36
|
+
/** Classifies a line as a JSON-RPC message, a batch (JSON array), JSON-looking garbage, or anything else (logs, blank lines). */
|
|
37
|
+
export declare function parseLine(text: string): ParsedLine;
|
|
38
|
+
/** Serializes one message for the wire. JSON.stringify never emits a raw newline, so the framing stays intact. */
|
|
39
|
+
export declare function encodeMessage(value: unknown): string;
|
|
40
|
+
/** A request expects a response: it has a method and a string or numeric id. */
|
|
41
|
+
export declare function isRequest(message: JsonRpcMessage): boolean;
|
|
42
|
+
export declare function isNotification(message: JsonRpcMessage): boolean;
|
|
43
|
+
export declare function isResponse(message: JsonRpcMessage): boolean;
|
|
44
|
+
/** Map key for a request id that keeps 1 and "1" apart. */
|
|
45
|
+
export declare function idKey(id: unknown): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framing for the MCP stdio transport: newline-delimited JSON-RPC 2.0
|
|
3
|
+
* messages. Lines are split on raw bytes, so chunks may break a line or a
|
|
4
|
+
* multi-byte character anywhere, and lines may end with \n or \r\n.
|
|
5
|
+
*/
|
|
6
|
+
const NEWLINE = 0x0a;
|
|
7
|
+
/** Splits a byte stream into lines without quadratic copying, however the stream is chunked. */
|
|
8
|
+
export class LineDecoder {
|
|
9
|
+
pending = [];
|
|
10
|
+
/** Feeds one chunk and returns every line it completed. */
|
|
11
|
+
push(chunk) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
let start = 0;
|
|
14
|
+
let newline = chunk.indexOf(NEWLINE, start);
|
|
15
|
+
while (newline !== -1) {
|
|
16
|
+
const piece = chunk.subarray(start, newline);
|
|
17
|
+
if (this.pending.length > 0) {
|
|
18
|
+
this.pending.push(piece);
|
|
19
|
+
lines.push(toLine(Buffer.concat(this.pending)));
|
|
20
|
+
this.pending = [];
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
lines.push(toLine(piece));
|
|
24
|
+
}
|
|
25
|
+
start = newline + 1;
|
|
26
|
+
newline = chunk.indexOf(NEWLINE, start);
|
|
27
|
+
}
|
|
28
|
+
if (start < chunk.length)
|
|
29
|
+
this.pending.push(chunk.subarray(start));
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
32
|
+
/** Returns the unterminated last line at end of stream, if there is one. */
|
|
33
|
+
flush() {
|
|
34
|
+
if (this.pending.length === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
const raw = Buffer.concat(this.pending);
|
|
37
|
+
this.pending = [];
|
|
38
|
+
return raw.length > 0 ? toLine(raw) : undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function toLine(raw) {
|
|
42
|
+
const text = raw.toString('utf8');
|
|
43
|
+
return { raw, text: text.endsWith('\r') ? text.slice(0, -1) : text };
|
|
44
|
+
}
|
|
45
|
+
/** Anything that begins, after any whitespace, with an object or array opener. JSON.parse allows less whitespace than other parsers do, so this is deliberately wide. */
|
|
46
|
+
const JSON_LIKE = /^\s*[{[]/;
|
|
47
|
+
/** Classifies a line as a JSON-RPC message, a batch (JSON array), JSON-looking garbage, or anything else (logs, blank lines). */
|
|
48
|
+
export function parseLine(text) {
|
|
49
|
+
// .NET and PowerShell servers can start their output with a byte order mark; clients that tolerate it must keep working.
|
|
50
|
+
const body = text.startsWith('') ? text.replace(/^+/, '') : text;
|
|
51
|
+
if (!JSON_LIKE.test(body))
|
|
52
|
+
return { kind: 'raw' };
|
|
53
|
+
let value;
|
|
54
|
+
try {
|
|
55
|
+
value = JSON.parse(body);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { kind: 'invalid' };
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value))
|
|
61
|
+
return { kind: 'batch', items: value };
|
|
62
|
+
if (value !== null && typeof value === 'object')
|
|
63
|
+
return { kind: 'message', message: value };
|
|
64
|
+
return { kind: 'invalid' };
|
|
65
|
+
}
|
|
66
|
+
/** Serializes one message for the wire. JSON.stringify never emits a raw newline, so the framing stays intact. */
|
|
67
|
+
export function encodeMessage(value) {
|
|
68
|
+
return JSON.stringify(value);
|
|
69
|
+
}
|
|
70
|
+
function hasId(message) {
|
|
71
|
+
return typeof message.id === 'string' || typeof message.id === 'number';
|
|
72
|
+
}
|
|
73
|
+
/** A request expects a response: it has a method and a string or numeric id. */
|
|
74
|
+
export function isRequest(message) {
|
|
75
|
+
return typeof message.method === 'string' && hasId(message);
|
|
76
|
+
}
|
|
77
|
+
export function isNotification(message) {
|
|
78
|
+
return typeof message.method === 'string' && !('id' in message);
|
|
79
|
+
}
|
|
80
|
+
export function isResponse(message) {
|
|
81
|
+
return message.method === undefined && hasId(message) && ('result' in message || 'error' in message);
|
|
82
|
+
}
|
|
83
|
+
/** Map key for a request id that keeps 1 and "1" apart. */
|
|
84
|
+
export function idKey(id) {
|
|
85
|
+
return typeof id === 'number' ? `n:${id}` : `s:${String(id)}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Severity } from '../types.js';
|
|
2
|
+
import type { ToolDefinition } from './fingerprint.js';
|
|
3
|
+
/**
|
|
4
|
+
* The lockfile pins every MCP tool definition the proxy has accepted, and
|
|
5
|
+
* holds definitions waiting for a human decision. It is plain, sorted JSON so
|
|
6
|
+
* it can be reviewed and committed.
|
|
7
|
+
*/
|
|
8
|
+
export interface PinnedTool {
|
|
9
|
+
hash: string;
|
|
10
|
+
definition: ToolDefinition;
|
|
11
|
+
pinnedAt: string;
|
|
12
|
+
}
|
|
13
|
+
export type PendingReason = 'changed' | 'new' | 'flagged';
|
|
14
|
+
export interface FindingSummary {
|
|
15
|
+
ruleId: string;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PendingTool {
|
|
20
|
+
hash: string;
|
|
21
|
+
definition: ToolDefinition;
|
|
22
|
+
detectedAt: string;
|
|
23
|
+
reason: PendingReason;
|
|
24
|
+
findings: FindingSummary[];
|
|
25
|
+
}
|
|
26
|
+
export interface ServerEntry {
|
|
27
|
+
command: string;
|
|
28
|
+
firstSeen: string;
|
|
29
|
+
updated: string;
|
|
30
|
+
tools: Record<string, PinnedTool>;
|
|
31
|
+
pending: Record<string, PendingTool>;
|
|
32
|
+
}
|
|
33
|
+
export interface Lockfile {
|
|
34
|
+
version: 1;
|
|
35
|
+
servers: Record<string, ServerEntry>;
|
|
36
|
+
}
|
|
37
|
+
export declare class LockfileError extends Error {
|
|
38
|
+
name: string;
|
|
39
|
+
}
|
|
40
|
+
/** <toolmoat home>/lock.json */
|
|
41
|
+
export declare function defaultLockPath(): string;
|
|
42
|
+
export declare function emptyLockfile(): Lockfile;
|
|
43
|
+
/** Reads and validates a lockfile. A missing file is an empty lockfile; a corrupt one throws LockfileError. */
|
|
44
|
+
export declare function readLockfile(file: string): Lockfile;
|
|
45
|
+
/** Serializes with stable key order so the file diffs cleanly in version control. */
|
|
46
|
+
export declare function serializeLockfile(lock: Lockfile): string;
|
|
47
|
+
/** Writes the whole lockfile atomically: temp file in the same directory, fsync, rename. */
|
|
48
|
+
export declare function writeLockfile(file: string, lock: Lockfile): void;
|
|
49
|
+
/**
|
|
50
|
+
* Read-modify-write of one server's entry under an advisory lock. The file is
|
|
51
|
+
* re-read inside the lock and only this server's entry is replaced, so
|
|
52
|
+
* several proxies (and `toolmoat approve`) can share one lockfile. `mutate`
|
|
53
|
+
* gets a private copy of the current entry and returns the new one, or
|
|
54
|
+
* undefined to remove the server. Returns the entry that was written.
|
|
55
|
+
*/
|
|
56
|
+
export declare function updateServer(file: string, name: string, mutate: (current: ServerEntry | undefined) => ServerEntry | undefined): ServerEntry | undefined;
|
|
57
|
+
/** Runs fn while holding <file>.lock, created with O_EXCL. Locks left by dead processes are broken. */
|
|
58
|
+
export declare function withFileLock<T>(file: string, fn: () => T): T;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { toolmoatHome } from '../util/paths.js';
|
|
5
|
+
import { canonicalJson, getOwn, setOwn } from './canonical.js';
|
|
6
|
+
export class LockfileError extends Error {
|
|
7
|
+
name = 'LockfileError';
|
|
8
|
+
}
|
|
9
|
+
/** <toolmoat home>/lock.json */
|
|
10
|
+
export function defaultLockPath() {
|
|
11
|
+
return path.join(toolmoatHome(), 'lock.json');
|
|
12
|
+
}
|
|
13
|
+
export function emptyLockfile() {
|
|
14
|
+
return { version: 1, servers: {} };
|
|
15
|
+
}
|
|
16
|
+
/** Reads and validates a lockfile. A missing file is an empty lockfile; a corrupt one throws LockfileError. */
|
|
17
|
+
export function readLockfile(file) {
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = fs.readFileSync(file, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (errorCode(error) === 'ENOENT')
|
|
24
|
+
return emptyLockfile();
|
|
25
|
+
throw new LockfileError(`cannot read lockfile ${file}: ${errorMessage(error)}`);
|
|
26
|
+
}
|
|
27
|
+
if (text.trim() === '')
|
|
28
|
+
return emptyLockfile();
|
|
29
|
+
let data;
|
|
30
|
+
try {
|
|
31
|
+
data = JSON.parse(text.replace(/^/, ''));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new LockfileError(`lockfile ${file} is not valid JSON: ${errorMessage(error)}`);
|
|
35
|
+
}
|
|
36
|
+
return validateLockfile(data, file);
|
|
37
|
+
}
|
|
38
|
+
function validateLockfile(data, file) {
|
|
39
|
+
if (!isObject(data))
|
|
40
|
+
throw new LockfileError(`lockfile ${file} must contain a JSON object`);
|
|
41
|
+
if (data.version !== 1)
|
|
42
|
+
throw new LockfileError(`lockfile ${file} has unsupported version ${JSON.stringify(data.version)} (expected 1)`);
|
|
43
|
+
const servers = data.servers ?? {};
|
|
44
|
+
if (!isObject(servers))
|
|
45
|
+
throw new LockfileError(`lockfile ${file}: "servers" must be an object`);
|
|
46
|
+
const out = emptyLockfile();
|
|
47
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
48
|
+
if (!isObject(entry))
|
|
49
|
+
throw new LockfileError(`lockfile ${file}: server "${name}" must be an object`);
|
|
50
|
+
setOwn(out.servers, name, normalizeEntry(entry));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function normalizeEntry(entry) {
|
|
55
|
+
return {
|
|
56
|
+
command: typeof entry.command === 'string' ? entry.command : '',
|
|
57
|
+
firstSeen: typeof entry.firstSeen === 'string' ? entry.firstSeen : '',
|
|
58
|
+
updated: typeof entry.updated === 'string' ? entry.updated : '',
|
|
59
|
+
tools: recordOf(entry.tools, normalizePinned),
|
|
60
|
+
pending: recordOf(entry.pending, normalizePending),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function recordOf(value, normalize) {
|
|
64
|
+
const out = {};
|
|
65
|
+
if (!isObject(value))
|
|
66
|
+
return out;
|
|
67
|
+
for (const [key, item] of Object.entries(value)) {
|
|
68
|
+
const normalized = isObject(item) ? normalize(item) : undefined;
|
|
69
|
+
if (normalized !== undefined)
|
|
70
|
+
setOwn(out, key, normalized);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function normalizePinned(item) {
|
|
75
|
+
if (typeof item.hash !== 'string' || !isObject(item.definition))
|
|
76
|
+
return undefined;
|
|
77
|
+
return { hash: item.hash, definition: item.definition, pinnedAt: typeof item.pinnedAt === 'string' ? item.pinnedAt : '' };
|
|
78
|
+
}
|
|
79
|
+
function normalizePending(item) {
|
|
80
|
+
if (typeof item.hash !== 'string' || !isObject(item.definition))
|
|
81
|
+
return undefined;
|
|
82
|
+
if (item.reason !== 'changed' && item.reason !== 'new' && item.reason !== 'flagged')
|
|
83
|
+
return undefined;
|
|
84
|
+
return {
|
|
85
|
+
hash: item.hash,
|
|
86
|
+
definition: item.definition,
|
|
87
|
+
detectedAt: typeof item.detectedAt === 'string' ? item.detectedAt : '',
|
|
88
|
+
reason: item.reason,
|
|
89
|
+
findings: Array.isArray(item.findings) ? item.findings.filter(isObject) : [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Serializes with stable key order so the file diffs cleanly in version control. */
|
|
93
|
+
export function serializeLockfile(lock) {
|
|
94
|
+
const servers = {};
|
|
95
|
+
for (const name of Object.keys(lock.servers).sort()) {
|
|
96
|
+
const entry = lock.servers[name];
|
|
97
|
+
setOwn(servers, name, {
|
|
98
|
+
command: entry.command,
|
|
99
|
+
firstSeen: entry.firstSeen,
|
|
100
|
+
updated: entry.updated,
|
|
101
|
+
tools: sortedRecord(entry.tools),
|
|
102
|
+
pending: sortedRecord(entry.pending),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return `${JSON.stringify({ version: 1, servers }, null, 2)}\n`;
|
|
106
|
+
}
|
|
107
|
+
function sortedRecord(record) {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const key of Object.keys(record).sort())
|
|
110
|
+
setOwn(out, key, JSON.parse(canonicalJson(record[key])));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Writes the whole lockfile atomically: temp file in the same directory, fsync, rename. */
|
|
114
|
+
export function writeLockfile(file, lock) {
|
|
115
|
+
atomicWrite(file, serializeLockfile(lock));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read-modify-write of one server's entry under an advisory lock. The file is
|
|
119
|
+
* re-read inside the lock and only this server's entry is replaced, so
|
|
120
|
+
* several proxies (and `toolmoat approve`) can share one lockfile. `mutate`
|
|
121
|
+
* gets a private copy of the current entry and returns the new one, or
|
|
122
|
+
* undefined to remove the server. Returns the entry that was written.
|
|
123
|
+
*/
|
|
124
|
+
export function updateServer(file, name, mutate) {
|
|
125
|
+
return withFileLock(file, () => {
|
|
126
|
+
const lock = readLockfile(file);
|
|
127
|
+
const before = getOwn(lock.servers, name);
|
|
128
|
+
const next = mutate(before === undefined ? undefined : structuredClone(before));
|
|
129
|
+
if (canonicalJson(next ?? null) === canonicalJson(before ?? null))
|
|
130
|
+
return before;
|
|
131
|
+
if (next === undefined)
|
|
132
|
+
delete lock.servers[name];
|
|
133
|
+
else
|
|
134
|
+
setOwn(lock.servers, name, next);
|
|
135
|
+
writeLockfile(file, lock);
|
|
136
|
+
return next;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const RETRYABLE = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
140
|
+
function atomicWrite(file, data) {
|
|
141
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
142
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
143
|
+
const fd = fs.openSync(tmp, 'w');
|
|
144
|
+
try {
|
|
145
|
+
fs.writeSync(fd, data);
|
|
146
|
+
fs.fsyncSync(fd);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
fs.closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
for (let attempt = 0;; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
fs.renameSync(tmp, file);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (attempt < 10 && RETRYABLE.has(errorCode(error) ?? '')) {
|
|
158
|
+
sleepSync(Math.min(10 * 2 ** attempt, 200));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
fs.unlinkSync(tmp);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// The temp file may already be gone; the original error is what matters.
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const LOCK_TIMEOUT_MS = 3000;
|
|
172
|
+
const STALE_LOCK_MS = 10_000;
|
|
173
|
+
/** Runs fn while holding <file>.lock, created with O_EXCL. Locks left by dead processes are broken. */
|
|
174
|
+
export function withFileLock(file, fn) {
|
|
175
|
+
const lockPath = `${file}.lock`;
|
|
176
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
177
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
178
|
+
for (let attempt = 0;; attempt++) {
|
|
179
|
+
try {
|
|
180
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
181
|
+
fs.writeSync(fd, String(process.pid));
|
|
182
|
+
fs.closeSync(fd);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const code = errorCode(error);
|
|
187
|
+
if (code !== 'EEXIST' && !RETRYABLE.has(code ?? ''))
|
|
188
|
+
throw error;
|
|
189
|
+
if (code === 'EEXIST' && isStaleLock(lockPath)) {
|
|
190
|
+
try {
|
|
191
|
+
fs.unlinkSync(lockPath);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Another process broke the stale lock first.
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (Date.now() > deadline)
|
|
199
|
+
throw new LockfileError(`lockfile ${file} is locked by another process (${lockPath})`);
|
|
200
|
+
sleepSync(Math.min(5 + attempt * 2, 25));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return fn();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
try {
|
|
208
|
+
fs.unlinkSync(lockPath);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Nothing useful to do if the lock vanished.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A lock is stale when it is too old or its owner process is gone. The file is
|
|
217
|
+
* checked again after the liveness probe: the owner may have released the lock
|
|
218
|
+
* and exited while we looked, and the path may now hold another process's
|
|
219
|
+
* live lock, which must not be broken.
|
|
220
|
+
*/
|
|
221
|
+
function isStaleLock(lockPath) {
|
|
222
|
+
try {
|
|
223
|
+
const before = fs.statSync(lockPath);
|
|
224
|
+
const owner = fs.readFileSync(lockPath, 'utf8');
|
|
225
|
+
const pid = Number.parseInt(owner, 10);
|
|
226
|
+
const expired = Date.now() - before.mtimeMs > STALE_LOCK_MS;
|
|
227
|
+
const orphaned = Number.isInteger(pid) && pid > 0 && pid !== process.pid && !processAlive(pid);
|
|
228
|
+
if (!expired && !orphaned)
|
|
229
|
+
return false;
|
|
230
|
+
const after = fs.statSync(lockPath);
|
|
231
|
+
return after.ino === before.ino && after.mtimeMs === before.mtimeMs && fs.readFileSync(lockPath, 'utf8') === owner;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function processAlive(pid) {
|
|
238
|
+
try {
|
|
239
|
+
process.kill(pid, 0);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return errorCode(error) === 'EPERM';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
247
|
+
function sleepSync(ms) {
|
|
248
|
+
Atomics.wait(sleepCell, 0, 0, ms);
|
|
249
|
+
}
|
|
250
|
+
function isObject(value) {
|
|
251
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
252
|
+
}
|
|
253
|
+
function errorCode(error) {
|
|
254
|
+
return isObject(error) && typeof error.code === 'string' ? error.code : undefined;
|
|
255
|
+
}
|
|
256
|
+
function errorMessage(error) {
|
|
257
|
+
return error instanceof Error ? error.message : String(error);
|
|
258
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a stable lockfile id from a server command: the npm/PyPI package
|
|
3
|
+
* for package runners (npx -y @scope/pkg@1.2 -> @scope/pkg), the script name
|
|
4
|
+
* for interpreters (node path/to/weather-server.js -> weather-server), the
|
|
5
|
+
* image for docker run, otherwise the executable's base name.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deriveServerName(command: string, args: readonly string[]): string;
|
|
8
|
+
/** A file name for the server's audit log: no separators or characters Windows rejects. */
|
|
9
|
+
export declare function fileSafeName(name: string): string;
|
|
10
|
+
/** Quotes an argument for display in a copy-pasteable command when it needs it. */
|
|
11
|
+
export declare function shellQuote(arg: string): string;
|
|
12
|
+
/** The command line as stored in the lockfile and shown to users. */
|
|
13
|
+
export declare function formatCommand(command: string, args: readonly string[]): string;
|
|
14
|
+
/**
|
|
15
|
+
* Masks credentials in a command line before it is stored in the lockfile (meant to be
|
|
16
|
+
* reviewed and committed) or the audit log: known token formats, passwords inside URLs,
|
|
17
|
+
* bearer and basic credentials, and the values of options and variables whose names say
|
|
18
|
+
* token, secret, password, key or auth.
|
|
19
|
+
*/
|
|
20
|
+
export declare function redactCommandLine(line: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Makes attacker-controlled text (tool names, descriptions, rule messages)
|
|
23
|
+
* safe for a terminal: control, C1, bidi, zero-width and tag characters
|
|
24
|
+
* become visible escapes, so nothing can move the cursor, recolor output or
|
|
25
|
+
* hide inside a line. Optionally truncates.
|
|
26
|
+
*/
|
|
27
|
+
export declare function safeText(text: string, max?: number): string;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { redactSecrets } from '../patterns/secrets.js';
|
|
3
|
+
import { printable, truncate } from '../util/text.js';
|
|
4
|
+
/** Package runners whose first positional argument names the package that is the server. */
|
|
5
|
+
const PACKAGE_RUNNERS = new Set(['npx', 'bunx', 'uvx', 'pnpx']);
|
|
6
|
+
/** Runners that take a subcommand first: `pnpm dlx pkg`, `npm exec pkg`, `pipx run pkg`. */
|
|
7
|
+
const SUBCOMMAND_RUNNERS = {
|
|
8
|
+
pnpm: ['dlx', 'exec'],
|
|
9
|
+
yarn: ['dlx', 'exec'],
|
|
10
|
+
npm: ['exec', 'x'],
|
|
11
|
+
pipx: ['run'],
|
|
12
|
+
bun: ['x', 'run'],
|
|
13
|
+
uv: ['run', 'tool'],
|
|
14
|
+
};
|
|
15
|
+
/** Interpreters whose first positional argument is the server script. */
|
|
16
|
+
const INTERPRETERS = new Set(['node', 'nodejs', 'python', 'python3', 'py', 'deno', 'tsx', 'ts-node', 'bun', 'ruby', 'php', 'java']);
|
|
17
|
+
/** Options of runners and interpreters that consume the next argument. */
|
|
18
|
+
const VALUE_OPTIONS = new Set([
|
|
19
|
+
'-p', '--package', '--from', '--with', '--python', '--directory', '--project', '--index-url', '--spec', '--registry',
|
|
20
|
+
'--cache', '--userconfig', '-r', '--require', '--import', '--loader', '--env-file', '-c', '--config', '--cwd',
|
|
21
|
+
'-e', '--env', '-v', '--volume', '--name', '--network', '-w', '--workdir', '-u', '--user', '--mount', '--entrypoint',
|
|
22
|
+
'--platform', '-l', '--label', '--publish', '--add-host', '--cpus', '-m', '--memory',
|
|
23
|
+
]);
|
|
24
|
+
/** Script names too generic to identify a server; the closest meaningful directory is used instead. */
|
|
25
|
+
const GENERIC_SCRIPTS = new Set(['index', 'main', 'server', 'cli', 'app', 'run', 'start', '__main__', 'mod', 'bin']);
|
|
26
|
+
const BUILD_DIRS = new Set(['dist', 'build', 'out', 'lib', 'src', 'bin', 'build-output', '.']);
|
|
27
|
+
/**
|
|
28
|
+
* Derives a stable lockfile id from a server command: the npm/PyPI package
|
|
29
|
+
* for package runners (npx -y @scope/pkg@1.2 -> @scope/pkg), the script name
|
|
30
|
+
* for interpreters (node path/to/weather-server.js -> weather-server), the
|
|
31
|
+
* image for docker run, otherwise the executable's base name.
|
|
32
|
+
*/
|
|
33
|
+
export function deriveServerName(command, args) {
|
|
34
|
+
const base = executableName(command);
|
|
35
|
+
let rest = [...args];
|
|
36
|
+
const subcommands = SUBCOMMAND_RUNNERS[base];
|
|
37
|
+
if (subcommands && rest[0] !== undefined && subcommands.includes(rest[0])) {
|
|
38
|
+
rest = rest.slice(1);
|
|
39
|
+
const target = firstPositional(rest);
|
|
40
|
+
if (target !== undefined && looksLikeScript(target))
|
|
41
|
+
return cleanName(scriptName(rest) ?? base);
|
|
42
|
+
return cleanName(packageName(rest) ?? base);
|
|
43
|
+
}
|
|
44
|
+
if (PACKAGE_RUNNERS.has(base))
|
|
45
|
+
return cleanName(packageName(rest) ?? base);
|
|
46
|
+
if (base === 'docker' || base === 'podman')
|
|
47
|
+
return cleanName(imageName(rest) ?? base);
|
|
48
|
+
if (INTERPRETERS.has(base))
|
|
49
|
+
return cleanName(scriptName(rest) ?? base);
|
|
50
|
+
return cleanName(base);
|
|
51
|
+
}
|
|
52
|
+
function executableName(command) {
|
|
53
|
+
const name = path.win32.basename(path.posix.basename(command));
|
|
54
|
+
return name.replace(/\.(?:exe|cmd|bat|com|ps1|sh)$/i, '').toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
function packageName(args) {
|
|
57
|
+
const explicit = optionValue(args, ['-p', '--package', '--from']);
|
|
58
|
+
const spec = explicit ?? firstPositional(args);
|
|
59
|
+
return spec === undefined ? undefined : stripVersion(spec);
|
|
60
|
+
}
|
|
61
|
+
function stripVersion(spec) {
|
|
62
|
+
const scoped = spec.startsWith('@');
|
|
63
|
+
const at = spec.indexOf('@', scoped ? 1 : 0);
|
|
64
|
+
let name = at > 0 ? spec.slice(0, at) : spec;
|
|
65
|
+
name = name.replace(/(?:==|>=|<=|~=|!=)[^\s]{0,100}$/, '').replace(/\[[^\]]{0,100}\]$/, '');
|
|
66
|
+
return name || spec;
|
|
67
|
+
}
|
|
68
|
+
function imageName(args) {
|
|
69
|
+
const runIndex = args.findIndex((a) => a === 'run');
|
|
70
|
+
const image = firstPositional(runIndex === -1 ? args : args.slice(runIndex + 1));
|
|
71
|
+
if (!image)
|
|
72
|
+
return undefined;
|
|
73
|
+
const last = image.split('/').pop() ?? image;
|
|
74
|
+
return last.replace(/[:@][^/]{0,200}$/, '') || last;
|
|
75
|
+
}
|
|
76
|
+
function scriptName(args) {
|
|
77
|
+
const moduleName = optionValue(args, ['-m']);
|
|
78
|
+
if (moduleName)
|
|
79
|
+
return moduleName;
|
|
80
|
+
const script = firstPositional(args);
|
|
81
|
+
if (!script)
|
|
82
|
+
return undefined;
|
|
83
|
+
const parts = script.split(/[\\/]+/).filter(Boolean);
|
|
84
|
+
const file = parts.pop() ?? script;
|
|
85
|
+
const stem = file.replace(/\.(?:[cm]?[jt]s|py|rb|php|jar)$/i, '');
|
|
86
|
+
if (!GENERIC_SCRIPTS.has(stem.toLowerCase()))
|
|
87
|
+
return stem;
|
|
88
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
89
|
+
const dir = parts[i];
|
|
90
|
+
if (!BUILD_DIRS.has(dir.toLowerCase()) && !/^[a-z]:$/i.test(dir))
|
|
91
|
+
return dir;
|
|
92
|
+
}
|
|
93
|
+
return stem;
|
|
94
|
+
}
|
|
95
|
+
function looksLikeScript(arg) {
|
|
96
|
+
if (arg.startsWith('@'))
|
|
97
|
+
return false;
|
|
98
|
+
return /[\\/]/.test(arg) || /\.(?:[cm]?[jt]s|py|rb|php|jar)$/i.test(arg);
|
|
99
|
+
}
|
|
100
|
+
function firstPositional(args) {
|
|
101
|
+
for (let i = 0; i < args.length; i++) {
|
|
102
|
+
const arg = args[i];
|
|
103
|
+
if (arg === '--')
|
|
104
|
+
return args[i + 1];
|
|
105
|
+
if (!arg.startsWith('-'))
|
|
106
|
+
return arg;
|
|
107
|
+
if (!arg.includes('=') && VALUE_OPTIONS.has(arg))
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
function optionValue(args, names) {
|
|
113
|
+
for (let i = 0; i < args.length; i++) {
|
|
114
|
+
const arg = args[i];
|
|
115
|
+
if (arg === '--')
|
|
116
|
+
return undefined;
|
|
117
|
+
for (const name of names) {
|
|
118
|
+
if (arg === name)
|
|
119
|
+
return args[i + 1];
|
|
120
|
+
if (arg.startsWith(`${name}=`))
|
|
121
|
+
return arg.slice(name.length + 1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
function cleanName(name) {
|
|
127
|
+
const cleaned = name.replace(/[^A-Za-z0-9@/._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
128
|
+
return cleaned.slice(0, 120) || 'mcp-server';
|
|
129
|
+
}
|
|
130
|
+
/** A file name for the server's audit log: no separators or characters Windows rejects. */
|
|
131
|
+
export function fileSafeName(name) {
|
|
132
|
+
const safe = name.replace(/[^A-Za-z0-9._@-]+/g, '_').replace(/^[.]+/, '');
|
|
133
|
+
return safe.slice(0, 120) || 'server';
|
|
134
|
+
}
|
|
135
|
+
/** Quotes an argument for display in a copy-pasteable command when it needs it. */
|
|
136
|
+
export function shellQuote(arg) {
|
|
137
|
+
if (arg !== '' && /^[A-Za-z0-9@%+=:,./_~-]+$/.test(arg))
|
|
138
|
+
return arg;
|
|
139
|
+
return `"${arg.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
140
|
+
}
|
|
141
|
+
/** The command line as stored in the lockfile and shown to users. */
|
|
142
|
+
export function formatCommand(command, args) {
|
|
143
|
+
return [command, ...args].map(shellQuote).join(' ');
|
|
144
|
+
}
|
|
145
|
+
const SECRET_NAME = '(?<![a-z])(?:token|secret|passw(?:or)?d|pwd|api[-_]?key|credential|auth(?:orization)?)s?(?![a-z])';
|
|
146
|
+
const SECRET_VALUE = `("[^"]*"|'[^']*'|[^\\s"'&-][^\\s"'&]*)`;
|
|
147
|
+
/** name=value, as in KEY=..., --key=... and ?key=... */
|
|
148
|
+
const SECRET_ASSIGNMENT = new RegExp(`((?:^|[\\s"'?&;])[\\w.-]*${SECRET_NAME}[\\w.-]*)(=)${SECRET_VALUE}`, 'gi');
|
|
149
|
+
/** --key value: only for options, since a bare name followed by a word is usually a variable passed by name (docker -e TOKEN image). */
|
|
150
|
+
const SECRET_OPTION = new RegExp(`((?:^|\\s)--?[\\w.-]*${SECRET_NAME}[\\w.-]*)(\\s+)${SECRET_VALUE}`, 'gi');
|
|
151
|
+
/**
|
|
152
|
+
* Masks credentials in a command line before it is stored in the lockfile (meant to be
|
|
153
|
+
* reviewed and committed) or the audit log: known token formats, passwords inside URLs,
|
|
154
|
+
* bearer and basic credentials, and the values of options and variables whose names say
|
|
155
|
+
* token, secret, password, key or auth.
|
|
156
|
+
*/
|
|
157
|
+
export function redactCommandLine(line) {
|
|
158
|
+
return redactSecrets(line)
|
|
159
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@"']+:)[^\s@/"']+@/gi, '$1[redacted]@')
|
|
160
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 [redacted]')
|
|
161
|
+
.replace(SECRET_ASSIGNMENT, '$1$2[redacted]')
|
|
162
|
+
.replace(SECRET_OPTION, '$1$2[redacted]');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Makes attacker-controlled text (tool names, descriptions, rule messages)
|
|
166
|
+
* safe for a terminal: control, C1, bidi, zero-width and tag characters
|
|
167
|
+
* become visible escapes, so nothing can move the cursor, recolor output or
|
|
168
|
+
* hide inside a line. Optionally truncates.
|
|
169
|
+
*/
|
|
170
|
+
export function safeText(text, max) {
|
|
171
|
+
let out = printable(text).replace(/[\u0080-\u009f]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
172
|
+
if (max !== undefined)
|
|
173
|
+
out = truncate(out, max);
|
|
174
|
+
return out;
|
|
175
|
+
}
|