@cruxy/cli 0.19.0 → 0.21.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/dist/approval/classify.js +24 -0
- package/dist/approval/policy.js +7 -0
- package/dist/approval/prompt.js +7 -0
- package/dist/approval/types.d.ts +6 -0
- package/dist/brand/voice.d.ts +1 -1
- package/dist/brand/voice.js +1 -1
- package/dist/cli/commands/mcp.d.ts +9 -0
- package/dist/cli/commands/mcp.js +87 -0
- package/dist/cli/commands/run.js +30 -2
- package/dist/cli/program.js +2 -0
- package/dist/cli/session-factory.d.ts +2 -2
- package/dist/cli/session-factory.js +21 -2
- package/dist/config/schema.d.ts +344 -33
- package/dist/config/schema.js +94 -4
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +8 -0
- package/dist/errors/constructors.d.ts +40 -0
- package/dist/errors/constructors.js +113 -0
- package/dist/errors/types.d.ts +19 -0
- package/dist/errors/types.js +32 -0
- package/dist/lsp/client.d.ts +25 -0
- package/dist/lsp/client.js +43 -0
- package/dist/lsp/index.d.ts +8 -0
- package/dist/lsp/index.js +8 -0
- package/dist/lsp/pool.d.ts +48 -0
- package/dist/lsp/pool.js +132 -0
- package/dist/lsp/registry.d.ts +38 -0
- package/dist/lsp/registry.js +133 -0
- package/dist/lsp/server.d.ts +48 -0
- package/dist/lsp/server.js +264 -0
- package/dist/lsp/service.d.ts +44 -0
- package/dist/lsp/service.js +76 -0
- package/dist/lsp/tools/common.d.ts +23 -0
- package/dist/lsp/tools/common.js +75 -0
- package/dist/lsp/tools/find-definition.d.ts +23 -0
- package/dist/lsp/tools/find-definition.js +41 -0
- package/dist/lsp/tools/find-references.d.ts +23 -0
- package/dist/lsp/tools/find-references.js +41 -0
- package/dist/lsp/tools/get-diagnostics.d.ts +17 -0
- package/dist/lsp/tools/get-diagnostics.js +43 -0
- package/dist/lsp/tools/hover.d.ts +23 -0
- package/dist/lsp/tools/hover.js +38 -0
- package/dist/lsp/tools/index.d.ts +4 -0
- package/dist/lsp/tools/index.js +4 -0
- package/dist/lsp/transport.d.ts +39 -0
- package/dist/lsp/transport.js +208 -0
- package/dist/lsp/types.d.ts +107 -0
- package/dist/lsp/types.js +1 -0
- package/dist/mcp/adapter.d.ts +44 -0
- package/dist/mcp/adapter.js +70 -0
- package/dist/mcp/bounds.d.ts +35 -0
- package/dist/mcp/bounds.js +36 -0
- package/dist/mcp/client.d.ts +19 -0
- package/dist/mcp/client.js +93 -0
- package/dist/mcp/demarcate.d.ts +12 -0
- package/dist/mcp/demarcate.js +71 -0
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.js +8 -0
- package/dist/mcp/service.d.ts +54 -0
- package/dist/mcp/service.js +99 -0
- package/dist/mcp/transport.d.ts +30 -0
- package/dist/mcp/transport.js +188 -0
- package/dist/mcp/trust-gate.d.ts +35 -0
- package/dist/mcp/trust-gate.js +40 -0
- package/dist/mcp/trust.d.ts +52 -0
- package/dist/mcp/trust.js +111 -0
- package/dist/mcp/types.d.ts +52 -0
- package/dist/mcp/types.js +7 -0
- package/dist/tools/file/grep-files.d.ts +2 -2
- package/dist/tools/registry.js +3 -1
- package/dist/tools/types.d.ts +15 -1
- package/dist/utils/child-tree.d.ts +35 -0
- package/dist/utils/child-tree.js +76 -0
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { killTree, registerForCleanup } from "../utils/child-tree.js";
|
|
3
|
+
/**
|
|
4
|
+
* JSON-RPC 2.0 over an MCP server's stdio (C.27). Owns the child process: spawns
|
|
5
|
+
* it in its OWN process group (`detached`) so the whole tree is killable, frames
|
|
6
|
+
* messages as newline-delimited JSON (the MCP stdio wire format), correlates
|
|
7
|
+
* responses to requests by id, times out per request, and detects a crash.
|
|
8
|
+
*
|
|
9
|
+
* The process-lifecycle discipline is shared with the C.12 LSP transport via the
|
|
10
|
+
* `utils/child-tree` backstop: `detached` spawn + negative-PID `killTree` on
|
|
11
|
+
* dispose/crash, and a single process-exit kill-tree that reaps LSP and MCP
|
|
12
|
+
* trees alike. That is what makes "trusting a server runs its code" not also mean
|
|
13
|
+
* "leaking its process on exit".
|
|
14
|
+
*
|
|
15
|
+
* Security posture on the inbound side: a server MAY send us requests
|
|
16
|
+
* (`sampling/createMessage`, `roots/list`, `elicitation/create`). We answer
|
|
17
|
+
* `ping` and DECLINE everything else with a JSON-RPC "method not found" — cruxy
|
|
18
|
+
* never lets a server drive model sampling or read our roots. Server→client
|
|
19
|
+
* notifications are ignored.
|
|
20
|
+
*/
|
|
21
|
+
const GRACE_MS = 2000;
|
|
22
|
+
const METHOD_NOT_FOUND = -32601;
|
|
23
|
+
export class McpStdioTransport {
|
|
24
|
+
child;
|
|
25
|
+
nextId = 1;
|
|
26
|
+
pending = new Map();
|
|
27
|
+
crashHandler = null;
|
|
28
|
+
/** stdout parse buffer (a message may arrive across chunks). */
|
|
29
|
+
buffer = "";
|
|
30
|
+
disposed = false;
|
|
31
|
+
unregisterCleanup;
|
|
32
|
+
constructor(spec, root) {
|
|
33
|
+
// `detached` makes the child a process-group leader so the whole tree can be
|
|
34
|
+
// killed via a negative-PID signal — same discipline as run_command (C.16)
|
|
35
|
+
// and the LSP transport (C.12).
|
|
36
|
+
this.child = spawn(spec.command, spec.args, {
|
|
37
|
+
cwd: root,
|
|
38
|
+
detached: true,
|
|
39
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
40
|
+
env: { ...process.env, ...(spec.env ?? {}) },
|
|
41
|
+
});
|
|
42
|
+
this.unregisterCleanup = registerForCleanup(this.child.pid);
|
|
43
|
+
this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
|
|
44
|
+
// Server stderr is diagnostic only; surface nothing by default (it's noisy).
|
|
45
|
+
this.child.stderr?.on("data", () => { });
|
|
46
|
+
this.child.on("exit", (code, signal) => this.onExit(code, signal));
|
|
47
|
+
this.child.on("error", (err) => this.onSpawnError(err));
|
|
48
|
+
}
|
|
49
|
+
request(method, params, timeoutMs) {
|
|
50
|
+
if (this.disposed) {
|
|
51
|
+
return Promise.reject(new Error("transport disposed"));
|
|
52
|
+
}
|
|
53
|
+
const id = this.nextId++;
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
this.pending.delete(id);
|
|
57
|
+
reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`));
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
61
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
notify(method, params) {
|
|
65
|
+
if (this.disposed)
|
|
66
|
+
return;
|
|
67
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
68
|
+
}
|
|
69
|
+
onCrash(handler) {
|
|
70
|
+
this.crashHandler = handler;
|
|
71
|
+
}
|
|
72
|
+
async dispose(force = false) {
|
|
73
|
+
if (this.disposed)
|
|
74
|
+
return;
|
|
75
|
+
this.disposed = true;
|
|
76
|
+
this.unregisterCleanup();
|
|
77
|
+
for (const [, p] of this.pending) {
|
|
78
|
+
clearTimeout(p.timer);
|
|
79
|
+
p.reject(new Error("transport disposed"));
|
|
80
|
+
}
|
|
81
|
+
this.pending.clear();
|
|
82
|
+
// Closing stdin is MCP's shutdown signal; a well-behaved server then exits.
|
|
83
|
+
this.child.stdin?.end();
|
|
84
|
+
if (!this.child.pid || this.child.exitCode !== null)
|
|
85
|
+
return;
|
|
86
|
+
if (force) {
|
|
87
|
+
killTree(this.child.pid);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await new Promise((resolve) => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
killTree(this.child.pid);
|
|
93
|
+
resolve();
|
|
94
|
+
}, GRACE_MS);
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
this.child.once("exit", () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// ── framing (newline-delimited JSON) ──────────────────────────────────────────
|
|
103
|
+
send(message) {
|
|
104
|
+
// MCP stdio frames one JSON message per line; JSON.stringify never emits a
|
|
105
|
+
// raw newline, so a single `\n` terminator is an unambiguous delimiter.
|
|
106
|
+
this.child.stdin?.write(JSON.stringify(message) + "\n");
|
|
107
|
+
}
|
|
108
|
+
onStdout(chunk) {
|
|
109
|
+
this.buffer += chunk.toString("utf8");
|
|
110
|
+
for (;;) {
|
|
111
|
+
const nl = this.buffer.indexOf("\n");
|
|
112
|
+
if (nl === -1)
|
|
113
|
+
return;
|
|
114
|
+
const line = this.buffer.slice(0, nl);
|
|
115
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (trimmed !== "")
|
|
118
|
+
this.dispatch(trimmed);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
dispatch(text) {
|
|
122
|
+
let msg;
|
|
123
|
+
try {
|
|
124
|
+
msg = JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return; // ignore unparseable frames
|
|
128
|
+
}
|
|
129
|
+
// Response to one of our requests.
|
|
130
|
+
if (typeof msg.id === "number" &&
|
|
131
|
+
msg.method === undefined &&
|
|
132
|
+
(msg.result !== undefined || msg.error)) {
|
|
133
|
+
const pending = this.pending.get(msg.id);
|
|
134
|
+
if (!pending)
|
|
135
|
+
return;
|
|
136
|
+
this.pending.delete(msg.id);
|
|
137
|
+
clearTimeout(pending.timer);
|
|
138
|
+
if (msg.error)
|
|
139
|
+
pending.reject(new Error(msg.error.message ?? "MCP error"));
|
|
140
|
+
else
|
|
141
|
+
pending.resolve(msg.result);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Server → client REQUEST (has both method and id). Answer ping; decline
|
|
145
|
+
// everything else — cruxy never lets a server drive sampling/roots/elicit.
|
|
146
|
+
if (typeof msg.method === "string" && msg.id !== undefined) {
|
|
147
|
+
if (msg.method === "ping") {
|
|
148
|
+
this.send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.send({
|
|
152
|
+
jsonrpc: "2.0",
|
|
153
|
+
id: msg.id,
|
|
154
|
+
error: {
|
|
155
|
+
code: METHOD_NOT_FOUND,
|
|
156
|
+
message: `cruxy does not support server-initiated "${msg.method}"`,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Server → client notification (no id): ignored.
|
|
163
|
+
}
|
|
164
|
+
onExit(code, signal) {
|
|
165
|
+
this.unregisterCleanup();
|
|
166
|
+
if (this.disposed)
|
|
167
|
+
return; // an expected shutdown, not a crash
|
|
168
|
+
// Unexpected exit → a crash. Reap the whole group so a crash never orphans
|
|
169
|
+
// the server's own child processes.
|
|
170
|
+
killTree(this.child.pid);
|
|
171
|
+
for (const [, p] of this.pending) {
|
|
172
|
+
clearTimeout(p.timer);
|
|
173
|
+
p.reject(new Error(`MCP server exited (code ${code}, signal ${signal})`));
|
|
174
|
+
}
|
|
175
|
+
this.pending.clear();
|
|
176
|
+
this.crashHandler?.({ code, signal });
|
|
177
|
+
}
|
|
178
|
+
onSpawnError(err) {
|
|
179
|
+
if (this.disposed)
|
|
180
|
+
return;
|
|
181
|
+
for (const [, p] of this.pending) {
|
|
182
|
+
clearTimeout(p.timer);
|
|
183
|
+
p.reject(err);
|
|
184
|
+
}
|
|
185
|
+
this.pending.clear();
|
|
186
|
+
this.crashHandler?.({ code: null, signal: null });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpServerConfig } from "../config/index.js";
|
|
2
|
+
import { type McpTrustStore } from "./trust.js";
|
|
3
|
+
/**
|
|
4
|
+
* The connect-time trust decision (C.27). This is the gate that stands between a
|
|
5
|
+
* configured MCP server and it actually running. Its wording is deliberately
|
|
6
|
+
* blunt, because the escalation is real: a trusted stdio MCP server runs
|
|
7
|
+
* UNSANDBOXED with your full privileges. The shell sandbox (C.16) can box a
|
|
8
|
+
* command, but it cannot contain a trusted external program's own side effects —
|
|
9
|
+
* so "trust this server" literally means "run this third-party code as me".
|
|
10
|
+
*
|
|
11
|
+
* Behavior:
|
|
12
|
+
* - Already trusted (config fingerprint matches a recorded decision) → proceed.
|
|
13
|
+
* - Untrusted + interactive → show the disclosure, read one key; only `y` trusts
|
|
14
|
+
* (records the decision) and proceeds. Anything else (incl. EOF) → declined.
|
|
15
|
+
* - Untrusted + NON-interactive → throw {@link mcpUntrusted} (CRUXY_E_MCP_UNTRUSTED)
|
|
16
|
+
* BEFORE anything is spawned. Non-interactive NEVER auto-trusts.
|
|
17
|
+
*/
|
|
18
|
+
/** The minimal prompt surface — satisfied by the shared `defaultPromptIO`. */
|
|
19
|
+
export interface McpTrustIO {
|
|
20
|
+
write(text: string): void;
|
|
21
|
+
/** Read a single keypress; resolves "" on EOF / Ctrl-C (→ default-deny). */
|
|
22
|
+
readKey(): Promise<string>;
|
|
23
|
+
color: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface EnsureMcpTrustDeps {
|
|
26
|
+
store: McpTrustStore;
|
|
27
|
+
/** Whether cruxy can actually prompt (stdin is a TTY). */
|
|
28
|
+
interactive: boolean;
|
|
29
|
+
/** Prompt I/O; required to actually prompt when interactive. */
|
|
30
|
+
io?: McpTrustIO;
|
|
31
|
+
/** ISO-timestamp source for the recorded decision (injected for tests). */
|
|
32
|
+
now?: () => string;
|
|
33
|
+
}
|
|
34
|
+
export type McpTrustOutcome = "trusted" | "declined";
|
|
35
|
+
export declare function ensureMcpTrust(root: string, servers: Record<string, McpServerConfig>, deps: EnsureMcpTrustDeps): Promise<McpTrustOutcome>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mcpUntrusted } from "../errors/index.js";
|
|
2
|
+
import { themeForColor } from "../theme/index.js";
|
|
3
|
+
import { fingerprintMcpServers, isMcpTrusted, } from "./trust.js";
|
|
4
|
+
export async function ensureMcpTrust(root, servers, deps) {
|
|
5
|
+
const names = Object.keys(servers);
|
|
6
|
+
const fingerprint = fingerprintMcpServers(servers);
|
|
7
|
+
if (isMcpTrusted(deps.store, root, fingerprint))
|
|
8
|
+
return "trusted";
|
|
9
|
+
// Non-interactive: fail closed, before any spawn. Never auto-trust.
|
|
10
|
+
if (!deps.interactive || !deps.io) {
|
|
11
|
+
throw mcpUntrusted(root, names);
|
|
12
|
+
}
|
|
13
|
+
const io = deps.io;
|
|
14
|
+
io.write(disclosure(names, io.color));
|
|
15
|
+
const key = (await io.readKey()).toLowerCase();
|
|
16
|
+
io.write("\n");
|
|
17
|
+
if (key === "y") {
|
|
18
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
19
|
+
deps.store.record({ root, fingerprint, at: now() });
|
|
20
|
+
return "trusted";
|
|
21
|
+
}
|
|
22
|
+
return "declined";
|
|
23
|
+
}
|
|
24
|
+
/** The explicit escalation disclosure shown before trusting any server. */
|
|
25
|
+
function disclosure(names, color) {
|
|
26
|
+
const t = themeForColor(color);
|
|
27
|
+
const list = names.map((n) => ` • ${n}`).join("\n");
|
|
28
|
+
return [
|
|
29
|
+
`${t.danger(t.strong("! MCP servers want to connect"))} ${t.muted(`(${names.length})`)}`,
|
|
30
|
+
list,
|
|
31
|
+
"",
|
|
32
|
+
t.strong(" Trusting these servers runs their code on your machine with your FULL"),
|
|
33
|
+
t.strong(" privileges — they are NOT sandboxed. A trusted server can read and write"),
|
|
34
|
+
t.strong(" your files and make network calls, just like a program you ran yourself."),
|
|
35
|
+
t.muted(" Their tools are still individually approved before each call, and their"),
|
|
36
|
+
t.muted(" output is treated as untrusted data — but the process itself is not boxed."),
|
|
37
|
+
"",
|
|
38
|
+
` ${t.muted("Trust and connect these servers for this repo?")} ${t.strong("[y/N]")} `,
|
|
39
|
+
].join("\n");
|
|
40
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { McpServerConfig } from "../config/index.js";
|
|
2
|
+
import type { McpTrust } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The MCP-server trust model (C.27), a near-verbatim sibling of the C.19 hook and
|
|
5
|
+
* C.29 memory trust models. Trust is recorded in the GLOBAL dir
|
|
6
|
+
* (`~/.cruxy/mcp-trust.json`) — in the user's home, NEVER inside a repo — so
|
|
7
|
+
* cloning a repo carries zero trust and an attacker cannot ship a pre-trusted
|
|
8
|
+
* marker. It is its own file, independent of hook/memory trust.
|
|
9
|
+
*
|
|
10
|
+
* Trust is bound to a {@link fingerprintMcpServers fingerprint} of the exact MCP
|
|
11
|
+
* server config seen at trust time and re-checked on every run: if the config
|
|
12
|
+
* changes (a command / args / url / env edit), the fingerprint no longer matches
|
|
13
|
+
* and trust is stale → not trusted until re-granted. This is what defeats
|
|
14
|
+
* trust-then-swap. Because trusting a server means running its code UNSANDBOXED
|
|
15
|
+
* with your privileges, that staleness check is the load-bearing defense.
|
|
16
|
+
*/
|
|
17
|
+
/** ~/.cruxy/mcp-trust.json */
|
|
18
|
+
export declare function mcpTrustPath(): string;
|
|
19
|
+
/**
|
|
20
|
+
* A stable content fingerprint of a repo's configured MCP servers. Canonical by
|
|
21
|
+
* construction so a benign reformat of the config (reindent, reordered keys)
|
|
22
|
+
* does NOT change it, while any real change to what would be executed DOES:
|
|
23
|
+
* - only the meaning-bearing fields are hashed (server id, command, args, url,
|
|
24
|
+
* and env as sorted key=value pairs);
|
|
25
|
+
* - args/env are normalized to a fixed order;
|
|
26
|
+
* - servers are sorted by id and serialized with a fixed field order.
|
|
27
|
+
*
|
|
28
|
+
* The empty set has a fixed, stable fingerprint (trusting "no servers" is
|
|
29
|
+
* meaningful; adding the first server re-gates).
|
|
30
|
+
*/
|
|
31
|
+
export declare function fingerprintMcpServers(servers: Record<string, McpServerConfig>): string;
|
|
32
|
+
/** The persisted trust seam — file-backed in production, injectable for tests. */
|
|
33
|
+
export interface McpTrustStore {
|
|
34
|
+
/** The recorded decision for a repo root, or undefined if never trusted. */
|
|
35
|
+
get(root: string): McpTrust | undefined;
|
|
36
|
+
/** Persist a trust decision (overwrites any prior one for the same root). */
|
|
37
|
+
record(trust: McpTrust): void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Is this repo's current MCP server config trusted? True only when a decision
|
|
41
|
+
* exists AND its fingerprint matches the current one — a changed config is
|
|
42
|
+
* treated as untrusted (stale), forcing a fresh decision before any server runs.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isMcpTrusted(store: McpTrustStore, root: string, currentFingerprint: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The real store, persisting to `~/.cruxy/mcp-trust.json` as `{ [root]: McpTrust }`.
|
|
47
|
+
* Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
|
|
48
|
+
* (fail-closed — a broken trust file must never grant trust to unsandboxed code).
|
|
49
|
+
*/
|
|
50
|
+
export declare function fileMcpTrustStore(file?: string): McpTrustStore;
|
|
51
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
52
|
+
export declare function memoryMcpTrustStore(seed?: McpTrust[]): McpTrustStore;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { globalDir } from "../config/paths.js";
|
|
5
|
+
import { MCP_TRUST_FILE_NAME } from "../constants.js";
|
|
6
|
+
/**
|
|
7
|
+
* The MCP-server trust model (C.27), a near-verbatim sibling of the C.19 hook and
|
|
8
|
+
* C.29 memory trust models. Trust is recorded in the GLOBAL dir
|
|
9
|
+
* (`~/.cruxy/mcp-trust.json`) — in the user's home, NEVER inside a repo — so
|
|
10
|
+
* cloning a repo carries zero trust and an attacker cannot ship a pre-trusted
|
|
11
|
+
* marker. It is its own file, independent of hook/memory trust.
|
|
12
|
+
*
|
|
13
|
+
* Trust is bound to a {@link fingerprintMcpServers fingerprint} of the exact MCP
|
|
14
|
+
* server config seen at trust time and re-checked on every run: if the config
|
|
15
|
+
* changes (a command / args / url / env edit), the fingerprint no longer matches
|
|
16
|
+
* and trust is stale → not trusted until re-granted. This is what defeats
|
|
17
|
+
* trust-then-swap. Because trusting a server means running its code UNSANDBOXED
|
|
18
|
+
* with your privileges, that staleness check is the load-bearing defense.
|
|
19
|
+
*/
|
|
20
|
+
/** ~/.cruxy/mcp-trust.json */
|
|
21
|
+
export function mcpTrustPath() {
|
|
22
|
+
return path.join(globalDir(), MCP_TRUST_FILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A stable content fingerprint of a repo's configured MCP servers. Canonical by
|
|
26
|
+
* construction so a benign reformat of the config (reindent, reordered keys)
|
|
27
|
+
* does NOT change it, while any real change to what would be executed DOES:
|
|
28
|
+
* - only the meaning-bearing fields are hashed (server id, command, args, url,
|
|
29
|
+
* and env as sorted key=value pairs);
|
|
30
|
+
* - args/env are normalized to a fixed order;
|
|
31
|
+
* - servers are sorted by id and serialized with a fixed field order.
|
|
32
|
+
*
|
|
33
|
+
* The empty set has a fixed, stable fingerprint (trusting "no servers" is
|
|
34
|
+
* meaningful; adding the first server re-gates).
|
|
35
|
+
*/
|
|
36
|
+
export function fingerprintMcpServers(servers) {
|
|
37
|
+
const canonical = Object.entries(servers)
|
|
38
|
+
.map(([id, s]) => [
|
|
39
|
+
id,
|
|
40
|
+
s.command ?? "",
|
|
41
|
+
[...(s.args ?? [])],
|
|
42
|
+
s.url ?? "",
|
|
43
|
+
Object.entries(s.env ?? {})
|
|
44
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
45
|
+
.sort(),
|
|
46
|
+
])
|
|
47
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
48
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Is this repo's current MCP server config trusted? True only when a decision
|
|
52
|
+
* exists AND its fingerprint matches the current one — a changed config is
|
|
53
|
+
* treated as untrusted (stale), forcing a fresh decision before any server runs.
|
|
54
|
+
*/
|
|
55
|
+
export function isMcpTrusted(store, root, currentFingerprint) {
|
|
56
|
+
const record = store.get(path.resolve(root));
|
|
57
|
+
return record !== undefined && record.fingerprint === currentFingerprint;
|
|
58
|
+
}
|
|
59
|
+
// ── file-backed store ─────────────────────────────────────────────────────────
|
|
60
|
+
/**
|
|
61
|
+
* The real store, persisting to `~/.cruxy/mcp-trust.json` as `{ [root]: McpTrust }`.
|
|
62
|
+
* Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
|
|
63
|
+
* (fail-closed — a broken trust file must never grant trust to unsandboxed code).
|
|
64
|
+
*/
|
|
65
|
+
export function fileMcpTrustStore(file = mcpTrustPath()) {
|
|
66
|
+
let cache = null;
|
|
67
|
+
const load = () => {
|
|
68
|
+
if (cache)
|
|
69
|
+
return cache;
|
|
70
|
+
try {
|
|
71
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
72
|
+
cache =
|
|
73
|
+
raw && typeof raw === "object" ? raw : {};
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Missing or corrupt → no trust (fail-closed).
|
|
77
|
+
cache = {};
|
|
78
|
+
}
|
|
79
|
+
return cache;
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
get(root) {
|
|
83
|
+
return load()[path.resolve(root)];
|
|
84
|
+
},
|
|
85
|
+
record(trust) {
|
|
86
|
+
const store = load();
|
|
87
|
+
store[path.resolve(trust.root)] = {
|
|
88
|
+
...trust,
|
|
89
|
+
root: path.resolve(trust.root),
|
|
90
|
+
};
|
|
91
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
// 0600: trust records name local paths; keep them owner-only.
|
|
93
|
+
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
94
|
+
if (existsSync(file))
|
|
95
|
+
cache = store;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
100
|
+
export function memoryMcpTrustStore(seed = []) {
|
|
101
|
+
const store = new Map();
|
|
102
|
+
for (const t of seed)
|
|
103
|
+
store.set(path.resolve(t.root), t);
|
|
104
|
+
return {
|
|
105
|
+
get: (root) => store.get(path.resolve(root)),
|
|
106
|
+
record: (trust) => void store.set(path.resolve(trust.root), {
|
|
107
|
+
...trust,
|
|
108
|
+
root: path.resolve(trust.root),
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the MCP client (C.27). The wire protocol is JSON-RPC 2.0; the
|
|
3
|
+
* transport seam below lets tests inject a fake peer so no real server binary is
|
|
4
|
+
* required. Every type here is deliberately small — the security-bearing logic
|
|
5
|
+
* lives in `adapter.ts` (the single seam), not in these shapes.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* A recorded MCP-trust decision for one repo root. Trusting a server runs its
|
|
9
|
+
* code UNSANDBOXED with your privileges, so the decision is bound to a
|
|
10
|
+
* fingerprint of the exact server config and re-checked every run. Lives ONLY in
|
|
11
|
+
* `~/.cruxy/mcp-trust.json` (never in a repo), so a clone carries zero trust.
|
|
12
|
+
*/
|
|
13
|
+
export interface McpTrust {
|
|
14
|
+
/** Absolute project root. */
|
|
15
|
+
root: string;
|
|
16
|
+
/** sha256 of the canonicalized MCP server config (see `fingerprintMcpServers`). */
|
|
17
|
+
fingerprint: string;
|
|
18
|
+
/** ISO timestamp the decision was recorded. */
|
|
19
|
+
at: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The transport seam — JSON-RPC over some duplex channel (stdio in production).
|
|
23
|
+
* Fake implementations back the unit tests; the real one (`McpStdioTransport`)
|
|
24
|
+
* owns a child process and reaps its whole tree on teardown.
|
|
25
|
+
*/
|
|
26
|
+
export interface McpTransport {
|
|
27
|
+
/** Send a request and await its correlated response (rejects on timeout/error). */
|
|
28
|
+
request(method: string, params: unknown, timeoutMs: number): Promise<unknown>;
|
|
29
|
+
/** Fire-and-forget notification (no response). */
|
|
30
|
+
notify(method: string, params: unknown): void;
|
|
31
|
+
/** Register the crash callback (unexpected child exit). */
|
|
32
|
+
onCrash(handler: (info: {
|
|
33
|
+
code: number | null;
|
|
34
|
+
signal: string | null;
|
|
35
|
+
}) => void): void;
|
|
36
|
+
/** Shut the transport (and its process tree) down. `force` skips the grace window. */
|
|
37
|
+
dispose(force?: boolean): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** One tool exactly as a server advertises it in `tools/list` (untrusted input). */
|
|
40
|
+
export interface RawMcpTool {
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
/** The server's own JSON Schema for the tool's arguments (untrusted). */
|
|
44
|
+
inputSchema?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
/** A `tools/call` outcome, normalized to flat text plus the server's error flag. */
|
|
47
|
+
export interface McpCallResult {
|
|
48
|
+
/** Flattened textual content of the result (non-text blocks are summarized). */
|
|
49
|
+
text: string;
|
|
50
|
+
/** The server marked this result an error (still returned as data, demarcated). */
|
|
51
|
+
isError: boolean;
|
|
52
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the MCP client (C.27). The wire protocol is JSON-RPC 2.0; the
|
|
3
|
+
* transport seam below lets tests inject a fake peer so no real server binary is
|
|
4
|
+
* required. Every type here is deliberately small — the security-bearing logic
|
|
5
|
+
* lives in `adapter.ts` (the single seam), not in these shapes.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
@@ -9,15 +9,15 @@ declare const parameters: z.ZodObject<{
|
|
|
9
9
|
}, "strip", z.ZodTypeAny, {
|
|
10
10
|
pattern: string;
|
|
11
11
|
path?: string | undefined;
|
|
12
|
+
maxResults?: number | undefined;
|
|
12
13
|
glob?: string | undefined;
|
|
13
14
|
ignoreCase?: boolean | undefined;
|
|
14
|
-
maxResults?: number | undefined;
|
|
15
15
|
}, {
|
|
16
16
|
pattern: string;
|
|
17
17
|
path?: string | undefined;
|
|
18
|
+
maxResults?: number | undefined;
|
|
18
19
|
glob?: string | undefined;
|
|
19
20
|
ignoreCase?: boolean | undefined;
|
|
20
|
-
maxResults?: number | undefined;
|
|
21
21
|
}>;
|
|
22
22
|
/**
|
|
23
23
|
* Search file *contents* for a regex within the project root. Read-only — no
|
package/dist/tools/registry.js
CHANGED
|
@@ -38,7 +38,9 @@ export class ToolRegistry {
|
|
|
38
38
|
return this.list().map((tool) => ({
|
|
39
39
|
name: tool.name,
|
|
40
40
|
description: tool.description,
|
|
41
|
-
|
|
41
|
+
// A proxied tool (MCP, C.27) advertises its own bounds-capped schema
|
|
42
|
+
// verbatim; everything else is derived from its zod `parameters`.
|
|
43
|
+
input_schema: tool.rawInputSchema ?? toInputSchema(tool.parameters),
|
|
42
44
|
}));
|
|
43
45
|
}
|
|
44
46
|
}
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -102,11 +102,16 @@ export type ActionPreview =
|
|
|
102
102
|
*/
|
|
103
103
|
export interface ApproveAction {
|
|
104
104
|
/** The category of side effect being requested. */
|
|
105
|
-
kind: "write" | "edit" | "shell" | "patch" | "vcs" | "rollback" | "test";
|
|
105
|
+
kind: "write" | "edit" | "shell" | "patch" | "vcs" | "rollback" | "test" | "mcp";
|
|
106
106
|
/** Absolute resolved path the action targets (write/edit). */
|
|
107
107
|
path?: string;
|
|
108
108
|
/** The command to run (shell / test). */
|
|
109
109
|
command?: string;
|
|
110
|
+
/** MCP tool call (C.27): the server id and the tool name being invoked. The
|
|
111
|
+
* gate keys a session grant on this exact pair, so approving one MCP tool never
|
|
112
|
+
* covers another — and a server can never mark its own tool low-risk. */
|
|
113
|
+
server?: string;
|
|
114
|
+
tool?: string;
|
|
110
115
|
/** Exact-change preview rendered above the prompt (write/edit/patch/vcs/rollback). */
|
|
111
116
|
preview?: ActionPreview;
|
|
112
117
|
}
|
|
@@ -154,6 +159,15 @@ export interface Tool<Schema extends ZodTypeAny = ZodTypeAny> {
|
|
|
154
159
|
description: string;
|
|
155
160
|
/** Zod schema for the tool's input arguments. */
|
|
156
161
|
parameters: Schema;
|
|
162
|
+
/**
|
|
163
|
+
* An optional pre-rendered JSON Schema to advertise to the provider *verbatim*
|
|
164
|
+
* instead of deriving one from {@link parameters}. Used only by proxied tools
|
|
165
|
+
* whose schema originates elsewhere and cannot be reconstructed from zod — the
|
|
166
|
+
* MCP adapter (C.27) sets this to a server's own (bounds-capped) input schema
|
|
167
|
+
* while keeping a permissive `parameters` for local validation. Built-in tools
|
|
168
|
+
* leave it unset and are advertised from their zod schema as before.
|
|
169
|
+
*/
|
|
170
|
+
rawInputSchema?: Record<string, unknown>;
|
|
157
171
|
/** Run the tool against validated `input` and the ambient `ctx`. */
|
|
158
172
|
execute(input: z.infer<Schema>, ctx: ToolContext): Promise<ToolResult>;
|
|
159
173
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared no-orphan machinery for long-lived child processes (C.12 LSP servers,
|
|
3
|
+
* C.27 MCP servers). A child is spawned `detached` so it leads its own process
|
|
4
|
+
* group; every kill here uses a **negative-PID** `SIGKILL` so the whole tree —
|
|
5
|
+
* the child AND any grandchildren it forked (gopls's `go`, an MCP server's
|
|
6
|
+
* helper) — dies together.
|
|
7
|
+
*
|
|
8
|
+
* A per-session graceful shutdown covers the normal path, but a hard exit
|
|
9
|
+
* (Ctrl-C, an uncaught throw) would otherwise orphan these trees. So every live
|
|
10
|
+
* child's pid is tracked in one process-wide set and force-killed on teardown.
|
|
11
|
+
* Handlers are installed ONCE, lazily, on the first registration — so unit tests
|
|
12
|
+
* that never spawn a real process never install them.
|
|
13
|
+
*
|
|
14
|
+
* This module is deliberately transport-agnostic: LSP (Content-Length framing)
|
|
15
|
+
* and MCP (newline-delimited JSON) share the SAME backstop, so `killTrackedTrees`
|
|
16
|
+
* on exit reaps both and there is a single source of truth for "no orphans".
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Kill a process's entire group (POSIX negative-PID `SIGKILL`), falling back to
|
|
20
|
+
* a direct kill when there is no group (or on win32). Swallows errors — the
|
|
21
|
+
* process may already be gone.
|
|
22
|
+
*/
|
|
23
|
+
export declare function killTree(pid: number | undefined): void;
|
|
24
|
+
/**
|
|
25
|
+
* Force-kill the process group of every tracked-but-not-yet-shut-down child,
|
|
26
|
+
* then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
|
|
27
|
+
* handlers run — the last line against orphaned server trees on a hard exit.
|
|
28
|
+
* Exported so it is directly testable without raising real process signals.
|
|
29
|
+
* Idempotent: a second call is a no-op.
|
|
30
|
+
*/
|
|
31
|
+
export declare function killTrackedTrees(): void;
|
|
32
|
+
/** Number of child trees currently tracked by the exit backstop (for tests). */
|
|
33
|
+
export declare function trackedTreeCount(): number;
|
|
34
|
+
/** Track a live child for the exit backstop; returns a deregister callback. */
|
|
35
|
+
export declare function registerForCleanup(pid: number | undefined): () => void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared no-orphan machinery for long-lived child processes (C.12 LSP servers,
|
|
3
|
+
* C.27 MCP servers). A child is spawned `detached` so it leads its own process
|
|
4
|
+
* group; every kill here uses a **negative-PID** `SIGKILL` so the whole tree —
|
|
5
|
+
* the child AND any grandchildren it forked (gopls's `go`, an MCP server's
|
|
6
|
+
* helper) — dies together.
|
|
7
|
+
*
|
|
8
|
+
* A per-session graceful shutdown covers the normal path, but a hard exit
|
|
9
|
+
* (Ctrl-C, an uncaught throw) would otherwise orphan these trees. So every live
|
|
10
|
+
* child's pid is tracked in one process-wide set and force-killed on teardown.
|
|
11
|
+
* Handlers are installed ONCE, lazily, on the first registration — so unit tests
|
|
12
|
+
* that never spawn a real process never install them.
|
|
13
|
+
*
|
|
14
|
+
* This module is deliberately transport-agnostic: LSP (Content-Length framing)
|
|
15
|
+
* and MCP (newline-delimited JSON) share the SAME backstop, so `killTrackedTrees`
|
|
16
|
+
* on exit reaps both and there is a single source of truth for "no orphans".
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Kill a process's entire group (POSIX negative-PID `SIGKILL`), falling back to
|
|
20
|
+
* a direct kill when there is no group (or on win32). Swallows errors — the
|
|
21
|
+
* process may already be gone.
|
|
22
|
+
*/
|
|
23
|
+
export function killTree(pid) {
|
|
24
|
+
if (pid === undefined)
|
|
25
|
+
return;
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-pid, "SIGKILL");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, "SIGKILL");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* already exited */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const livePids = new Set();
|
|
39
|
+
let handlersInstalled = false;
|
|
40
|
+
/**
|
|
41
|
+
* Force-kill the process group of every tracked-but-not-yet-shut-down child,
|
|
42
|
+
* then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
|
|
43
|
+
* handlers run — the last line against orphaned server trees on a hard exit.
|
|
44
|
+
* Exported so it is directly testable without raising real process signals.
|
|
45
|
+
* Idempotent: a second call is a no-op.
|
|
46
|
+
*/
|
|
47
|
+
export function killTrackedTrees() {
|
|
48
|
+
for (const pid of livePids)
|
|
49
|
+
killTree(pid);
|
|
50
|
+
livePids.clear();
|
|
51
|
+
}
|
|
52
|
+
/** Number of child trees currently tracked by the exit backstop (for tests). */
|
|
53
|
+
export function trackedTreeCount() {
|
|
54
|
+
return livePids.size;
|
|
55
|
+
}
|
|
56
|
+
function installExitHandlers() {
|
|
57
|
+
if (handlersInstalled)
|
|
58
|
+
return;
|
|
59
|
+
handlersInstalled = true;
|
|
60
|
+
process.once("exit", killTrackedTrees);
|
|
61
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
62
|
+
process.once(sig, () => {
|
|
63
|
+
killTrackedTrees();
|
|
64
|
+
// Restore default behavior and re-raise so the exit code is correct.
|
|
65
|
+
process.exit(130);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Track a live child for the exit backstop; returns a deregister callback. */
|
|
70
|
+
export function registerForCleanup(pid) {
|
|
71
|
+
if (pid === undefined)
|
|
72
|
+
return () => { };
|
|
73
|
+
installExitHandlers();
|
|
74
|
+
livePids.add(pid);
|
|
75
|
+
return () => livePids.delete(pid);
|
|
76
|
+
}
|