@cruxy/cli 0.26.0 → 0.28.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/cli/commands/mcp.js +106 -7
- package/dist/config/credentials.d.ts +9 -0
- package/dist/config/credentials.js +29 -1
- package/dist/config/manager.js +30 -3
- package/dist/config/schema.d.ts +182 -8
- package/dist/config/schema.js +43 -5
- package/dist/errors/constructors.d.ts +29 -0
- package/dist/errors/constructors.js +69 -0
- package/dist/errors/types.d.ts +15 -0
- package/dist/errors/types.js +18 -0
- package/dist/mcp/http-transport.d.ts +89 -0
- package/dist/mcp/http-transport.js +299 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/service.d.ts +19 -2
- package/dist/mcp/service.js +92 -20
- package/dist/mcp/trust-gate.d.ts +35 -11
- package/dist/mcp/trust-gate.js +87 -22
- package/dist/mcp/trust.d.ts +12 -2
- package/dist/mcp/trust.js +26 -2
- package/dist/mcp/types.d.ts +10 -0
- package/dist/mcp/url-guard.d.ts +48 -0
- package/dist/mcp/url-guard.js +62 -0
- package/dist/net/ip-guard.d.ts +55 -0
- package/dist/net/ip-guard.js +229 -0
- package/dist/render/index.d.ts +1 -0
- package/dist/render/index.js +1 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -0
- package/dist/render/tty-renderer.d.ts +17 -3
- package/dist/render/tty-renderer.js +58 -21
- package/dist/tools/file/apply-patch.js +12 -8
- package/dist/tools/file/edit-file.d.ts +0 -2
- package/dist/tools/file/edit-file.js +10 -19
- package/dist/tools/file/match.d.ts +43 -0
- package/dist/tools/file/match.js +127 -0
- package/dist/web/ssrf.d.ts +8 -22
- package/dist/web/ssrf.js +11 -183
- package/dist/web/types.d.ts +4 -2
- package/package.json +1 -1
package/dist/mcp/service.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CruxyConfig, McpServerConfig } from "../config/index.js";
|
|
2
|
+
import type { HostResolver } from "../net/ip-guard.js";
|
|
2
3
|
import type { Tool } from "../tools/types.js";
|
|
3
4
|
import { type McpTrustStore } from "./trust.js";
|
|
4
5
|
import { type McpTrustIO } from "./trust-gate.js";
|
|
@@ -21,12 +22,18 @@ interface ServiceLogger {
|
|
|
21
22
|
}
|
|
22
23
|
/** Explicit dependency overrides, for tests only. Production passes none. */
|
|
23
24
|
export interface McpServiceDeps {
|
|
24
|
-
/** Substitute the transport (a fake peer — no real server binary). */
|
|
25
|
-
transportFactory?: (server: string, cfg: McpServerConfig, root: string
|
|
25
|
+
/** Substitute the transport (a fake peer — no real server binary/socket). */
|
|
26
|
+
transportFactory?: (server: string, cfg: McpServerConfig, root: string,
|
|
27
|
+
/** Pre-validated, pinned address set (url servers only). */
|
|
28
|
+
addresses: string[],
|
|
29
|
+
/** Resolved auth headers (url servers only; C.27c). */
|
|
30
|
+
authHeaders?: Record<string, string>) => McpTransport;
|
|
26
31
|
/** Substitute the trust store. */
|
|
27
32
|
trustStore?: McpTrustStore;
|
|
28
33
|
/** ISO-timestamp source for a recorded trust decision. */
|
|
29
34
|
now?: () => string;
|
|
35
|
+
/** DNS resolver for the trust gate's url-endpoint capture + SSRF guard. */
|
|
36
|
+
resolveHost?: HostResolver;
|
|
30
37
|
}
|
|
31
38
|
export interface ConnectMcpToolsParams {
|
|
32
39
|
cwd: string;
|
|
@@ -43,6 +50,16 @@ export interface ConnectMcpToolsResult {
|
|
|
43
50
|
tools: Tool[];
|
|
44
51
|
}
|
|
45
52
|
export declare function connectMcpTools(params: ConnectMcpToolsParams): Promise<ConnectMcpToolsResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the auth headers to send to a `url` server (C.27c), or `undefined` for
|
|
55
|
+
* an unauthenticated/stdio server. Two sources, both user-owned and outside the
|
|
56
|
+
* repo: a `credentialRef` looked up SOLELY in `~/.cruxy/credentials.json` (never
|
|
57
|
+
* the env, never any config file), and raw `headers` (accepted only from
|
|
58
|
+
* user-scope config — the loader already rejected them from project scope). A
|
|
59
|
+
* named credential with no stored token throws {@link mcpAuth} (missing) so a
|
|
60
|
+
* dangling reference fails cleanly rather than connecting unauthenticated.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveMcpAuthHeaders(server: string, cfg: McpServerConfig, read?: (ref: string) => string | undefined): Record<string, string> | undefined;
|
|
46
63
|
/**
|
|
47
64
|
* Dispose every live MCP connection (session end / process teardown). Mirrors
|
|
48
65
|
* `resetLspServices`; the shared child-tree exit backstop reaps anything a hard
|
package/dist/mcp/service.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { readMcpCredential } from "../config/credentials.js";
|
|
3
|
+
import { CruxyError, ErrorCode, mcpAuth, mcpConnect } from "../errors/index.js";
|
|
3
4
|
import { mcpToolsFrom } from "./adapter.js";
|
|
4
5
|
import { McpClient } from "./client.js";
|
|
6
|
+
import { McpHttpAuthError, McpHttpTransport } from "./http-transport.js";
|
|
5
7
|
import { McpStdioTransport } from "./transport.js";
|
|
6
8
|
import { fileMcpTrustStore } from "./trust.js";
|
|
7
9
|
import { ensureMcpTrust } from "./trust-gate.js";
|
|
@@ -17,16 +19,32 @@ export async function connectMcpTools(params) {
|
|
|
17
19
|
return { tools: [] };
|
|
18
20
|
const root = path.resolve(params.cwd);
|
|
19
21
|
// Trust gate. Non-interactive + untrusted THROWS CRUXY_E_MCP_UNTRUSTED here,
|
|
20
|
-
// before any spawn. Interactive shows the disclosure; a
|
|
21
|
-
// nothing.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
// before any spawn OR socket/DNS (JC-C). Interactive shows the disclosure; a
|
|
23
|
+
// decline connects to nothing. A url server refused by the SSRF guard throws
|
|
24
|
+
// CRUXY_E_MCP_BLOCKED — surfaced (coded), non-fatal to the run.
|
|
25
|
+
let endpoints;
|
|
26
|
+
try {
|
|
27
|
+
const result = await ensureMcpTrust(root, servers, {
|
|
28
|
+
store: deps?.trustStore ?? fileMcpTrustStore(),
|
|
29
|
+
interactive,
|
|
30
|
+
io,
|
|
31
|
+
now: deps?.now,
|
|
32
|
+
resolveHost: deps?.resolveHost,
|
|
33
|
+
});
|
|
34
|
+
if (result.outcome === "declined") {
|
|
35
|
+
logger.info("mcp: servers not trusted — no MCP tools were loaded");
|
|
36
|
+
return { tools: [] };
|
|
37
|
+
}
|
|
38
|
+
endpoints = result.endpoints;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// Untrusted is the fail-closed supply-chain stop — stays fatal. Any other
|
|
42
|
+
// gate error (SSRF block, unresolvable endpoint) degrades the feature to
|
|
43
|
+
// "no MCP tools" with a coded, visible reason rather than crashing the run.
|
|
44
|
+
if (err instanceof CruxyError && err.code === ErrorCode.McpUntrusted)
|
|
45
|
+
throw err;
|
|
46
|
+
const coded = err instanceof CruxyError ? err : mcpConnect("url server", err);
|
|
47
|
+
logger.warn(`${coded.code}: ${coded.title} — ${coded.cause ?? ""}`);
|
|
30
48
|
return { tools: [] };
|
|
31
49
|
}
|
|
32
50
|
const timeouts = {
|
|
@@ -40,9 +58,21 @@ export async function connectMcpTools(params) {
|
|
|
40
58
|
};
|
|
41
59
|
const tools = [];
|
|
42
60
|
for (const [server, cfg] of Object.entries(servers)) {
|
|
43
|
-
|
|
61
|
+
// Resolve the credential (C.27c) BEFORE building the transport. A named
|
|
62
|
+
// credential with no token in ~/.cruxy fails cleanly (coded, visible) and the
|
|
63
|
+
// server simply contributes no tools — a bad reference never connects.
|
|
64
|
+
let authHeaders;
|
|
65
|
+
try {
|
|
66
|
+
authHeaders = resolveMcpAuthHeaders(server, cfg);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const coded = err instanceof CruxyError ? err : mcpConnect(server, err);
|
|
70
|
+
logger.warn(`${coded.code}: ${coded.title} — ${coded.cause ?? ""}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const transport = makeTransport(server, cfg, root, endpoints[server] ?? [], timeouts, deps, authHeaders);
|
|
44
74
|
if (!transport) {
|
|
45
|
-
logger.warn(`${mcpConnect(server).code}: server "${server}" uses an unsupported transport (
|
|
75
|
+
logger.warn(`${mcpConnect(server).code}: server "${server}" uses an unsupported transport (need \`command\` (stdio) or \`url\`)`);
|
|
46
76
|
continue;
|
|
47
77
|
}
|
|
48
78
|
const client = new McpClient(transport, timeouts);
|
|
@@ -61,20 +91,62 @@ export async function connectMcpTools(params) {
|
|
|
61
91
|
logger.debug(`mcp: connected "${server}" (${serverTools.length} tool(s))`);
|
|
62
92
|
}
|
|
63
93
|
catch (err) {
|
|
64
|
-
|
|
94
|
+
// A rejected credential (401/403) is coded distinctly from a connect
|
|
95
|
+
// failure so the user knows to fix the credential, not the network.
|
|
96
|
+
const coded = err instanceof McpHttpAuthError
|
|
97
|
+
? mcpAuth(server, { kind: "rejected", status: err.status })
|
|
98
|
+
: mcpConnect(server, err);
|
|
65
99
|
logger.warn(`${coded.code}: ${coded.title} — ${coded.cause ?? ""}`);
|
|
66
100
|
await client.dispose(true).catch(() => { });
|
|
67
101
|
}
|
|
68
102
|
}
|
|
69
103
|
return { tools };
|
|
70
104
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the auth headers to send to a `url` server (C.27c), or `undefined` for
|
|
107
|
+
* an unauthenticated/stdio server. Two sources, both user-owned and outside the
|
|
108
|
+
* repo: a `credentialRef` looked up SOLELY in `~/.cruxy/credentials.json` (never
|
|
109
|
+
* the env, never any config file), and raw `headers` (accepted only from
|
|
110
|
+
* user-scope config — the loader already rejected them from project scope). A
|
|
111
|
+
* named credential with no stored token throws {@link mcpAuth} (missing) so a
|
|
112
|
+
* dangling reference fails cleanly rather than connecting unauthenticated.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveMcpAuthHeaders(server, cfg, read = readMcpCredential) {
|
|
115
|
+
const headers = { ...(cfg.headers ?? {}) };
|
|
116
|
+
if (cfg.credentialRef) {
|
|
117
|
+
const token = read(cfg.credentialRef);
|
|
118
|
+
if (!token) {
|
|
119
|
+
throw mcpAuth(server, { kind: "missing", ref: cfg.credentialRef });
|
|
120
|
+
}
|
|
121
|
+
headers.Authorization = `Bearer ${token}`;
|
|
122
|
+
}
|
|
123
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the transport for a server: stdio (spawns a child) or network (an HTTP
|
|
127
|
+
* transport pinned to the already-validated `addresses`). Returns null for a
|
|
128
|
+
* server with neither. The stdio path is byte-identical to C.27 — network is a
|
|
129
|
+
* new branch, never a change to how stdio servers are constructed.
|
|
130
|
+
*/
|
|
131
|
+
function makeTransport(server, cfg, root, addresses, timeouts, deps, authHeaders) {
|
|
73
132
|
if (deps?.transportFactory)
|
|
74
|
-
return deps.transportFactory(server, cfg, root);
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
77
|
-
|
|
133
|
+
return deps.transportFactory(server, cfg, root, addresses, authHeaders);
|
|
134
|
+
if (cfg.command) {
|
|
135
|
+
return new McpStdioTransport({ command: cfg.command, args: cfg.args ?? [], env: cfg.env ?? {} }, root);
|
|
136
|
+
}
|
|
137
|
+
if (cfg.url) {
|
|
138
|
+
// Trust already resolved + SSRF-validated the URL and handed us the pinned
|
|
139
|
+
// address set; the transport opens NO socket until `initialize()` (JC-C).
|
|
140
|
+
// authHeaders reach ONLY this pinned https endpoint (transport re-checks).
|
|
141
|
+
return new McpHttpTransport({
|
|
142
|
+
url: cfg.url,
|
|
143
|
+
addresses,
|
|
144
|
+
connectTimeout: timeouts.startupTimeout,
|
|
145
|
+
requestTimeout: timeouts.requestTimeout,
|
|
146
|
+
authHeaders,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
78
150
|
}
|
|
79
151
|
/**
|
|
80
152
|
* Dispose every live MCP connection (session end / process teardown). Mirrors
|
package/dist/mcp/trust-gate.d.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
+
import { type HostResolver } from "../net/ip-guard.js";
|
|
1
2
|
import type { McpServerConfig } from "../config/index.js";
|
|
2
3
|
import { type McpTrustStore } from "./trust.js";
|
|
3
4
|
/**
|
|
4
|
-
* The connect-time trust decision (C.27
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The connect-time trust decision (C.27, extended for network transport in
|
|
6
|
+
* C.27b). This is the gate that stands between a configured MCP server and it
|
|
7
|
+
* actually running / being connected to.
|
|
8
|
+
*
|
|
9
|
+
* For a STDIO server the escalation is "run this third-party code UNSANDBOXED with
|
|
10
|
+
* your full privileges." For a NETWORK (`url`) server the escalation is different
|
|
11
|
+
* and is disclosed differently: cruxy does NOT run the server's code locally, but
|
|
12
|
+
* it will SEND your tool arguments to a remote endpoint over the network and treat
|
|
13
|
+
* its responses as untrusted data. Both are real; the wording branches so neither
|
|
14
|
+
* is over- nor under-stated.
|
|
10
15
|
*
|
|
11
16
|
* Behavior:
|
|
12
|
-
* - Already trusted
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
17
|
+
* - Already trusted → proceed. "Trusted" means the config fingerprint matches AND,
|
|
18
|
+
* for url servers, the resolved IP set still matches the set bound at trust time
|
|
19
|
+
* (JC-D). A changed IP set is treated like a changed command → stale → re-gate.
|
|
20
|
+
* - Untrusted + interactive → show the disclosure, read one key; only `y` trusts.
|
|
15
21
|
* - Untrusted + NON-interactive → throw {@link mcpUntrusted} (CRUXY_E_MCP_UNTRUSTED)
|
|
16
|
-
* BEFORE
|
|
22
|
+
* BEFORE any socket or DNS lookup. For network, CONNECTING IS THE ACTION, so a
|
|
23
|
+
* never-trusted config fails closed with ZERO network I/O (JC-C).
|
|
24
|
+
*
|
|
25
|
+
* Ordering that guarantees zero-DNS-before-trust: the "is any decision recorded?"
|
|
26
|
+
* and static-fingerprint checks are pure (no I/O). Endpoint resolution (DNS + the
|
|
27
|
+
* SSRF guard) runs ONLY after a recorded, static-matching config is found (to
|
|
28
|
+
* re-validate a previously-trusted repo) or after the user presses `y` (to record
|
|
29
|
+
* a fresh decision) — never on the path that throws for an untrusted clone.
|
|
17
30
|
*/
|
|
18
31
|
/** The minimal prompt surface — satisfied by the shared `defaultPromptIO`. */
|
|
19
32
|
export interface McpTrustIO {
|
|
@@ -30,6 +43,17 @@ export interface EnsureMcpTrustDeps {
|
|
|
30
43
|
io?: McpTrustIO;
|
|
31
44
|
/** ISO-timestamp source for the recorded decision (injected for tests). */
|
|
32
45
|
now?: () => string;
|
|
46
|
+
/** DNS resolver for url-server endpoint capture (injected for tests). */
|
|
47
|
+
resolveHost?: HostResolver;
|
|
33
48
|
}
|
|
34
49
|
export type McpTrustOutcome = "trusted" | "declined";
|
|
35
|
-
export
|
|
50
|
+
export interface EnsureMcpTrustResult {
|
|
51
|
+
outcome: McpTrustOutcome;
|
|
52
|
+
/**
|
|
53
|
+
* Validated, pinned address sets per url server (empty for stdio/declined). The
|
|
54
|
+
* SAME set that was compared for trust — the caller pins the connection to it so
|
|
55
|
+
* the addresses trusted are exactly the addresses dialed (no second resolve).
|
|
56
|
+
*/
|
|
57
|
+
endpoints: Record<string, string[]>;
|
|
58
|
+
}
|
|
59
|
+
export declare function ensureMcpTrust(root: string, servers: Record<string, McpServerConfig>, deps: EnsureMcpTrustDeps): Promise<EnsureMcpTrustResult>;
|
package/dist/mcp/trust-gate.js
CHANGED
|
@@ -1,40 +1,105 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mcpBlocked, mcpUntrusted } from "../errors/index.js";
|
|
3
|
+
import { BlockedHostError, defaultResolveHost, } from "../net/ip-guard.js";
|
|
2
4
|
import { themeForColor } from "../theme/index.js";
|
|
3
|
-
import {
|
|
5
|
+
import { resolveMcpEndpoints } from "./url-guard.js";
|
|
6
|
+
import { endpointsMatch, fingerprintMcpServers, } from "./trust.js";
|
|
4
7
|
export async function ensureMcpTrust(root, servers, deps) {
|
|
5
8
|
const names = Object.keys(servers);
|
|
6
9
|
const fingerprint = fingerprintMcpServers(servers);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
+
const hasUrlServer = names.some((n) => servers[n].url);
|
|
11
|
+
const resolveHost = deps.resolveHost ?? defaultResolveHost;
|
|
12
|
+
// Pure, no-I/O check first — an untrusted clone throws below with zero DNS.
|
|
13
|
+
const record = deps.store.get(path.resolve(root));
|
|
14
|
+
const staticMatch = record !== undefined && record.fingerprint === fingerprint;
|
|
15
|
+
if (staticMatch) {
|
|
16
|
+
if (!hasUrlServer)
|
|
17
|
+
return { outcome: "trusted", endpoints: {} };
|
|
18
|
+
// A previously-trusted config with url servers: re-resolve + re-validate now
|
|
19
|
+
// (SSRF guard) and compare the IP set to the one bound at trust time (JC-D).
|
|
20
|
+
// This DNS runs only for an already-recorded config, never for a clone.
|
|
21
|
+
const current = await resolveEndpointsOrBlocked(servers, resolveHost);
|
|
22
|
+
if (endpointsMatch(record.endpoints, current)) {
|
|
23
|
+
return { outcome: "trusted", endpoints: current };
|
|
24
|
+
}
|
|
25
|
+
// IP set drifted → stale → fall through and re-gate (endpointChanged=true).
|
|
26
|
+
}
|
|
27
|
+
// Not trusted (no record / static mismatch / endpoint drift).
|
|
10
28
|
if (!deps.interactive || !deps.io) {
|
|
29
|
+
// Fail closed BEFORE any spawn/connect. Non-interactive NEVER auto-trusts.
|
|
11
30
|
throw mcpUntrusted(root, names);
|
|
12
31
|
}
|
|
13
32
|
const io = deps.io;
|
|
14
|
-
io.write(disclosure(names, io.color));
|
|
33
|
+
io.write(disclosure(names, servers, io.color, staticMatch));
|
|
15
34
|
const key = (await io.readKey()).toLowerCase();
|
|
16
35
|
io.write("\n");
|
|
17
|
-
if (key
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
36
|
+
if (key !== "y")
|
|
37
|
+
return { outcome: "declined", endpoints: {} };
|
|
38
|
+
// Trust granted. Capture the current endpoint set (DNS + SSRF guard) and bind it
|
|
39
|
+
// into the decision. If validation refuses (SSRF/scheme), do NOT record trust.
|
|
40
|
+
const endpoints = hasUrlServer
|
|
41
|
+
? await resolveEndpointsOrBlocked(servers, resolveHost)
|
|
42
|
+
: {};
|
|
43
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
44
|
+
deps.store.record({ root, fingerprint, at: now(), endpoints });
|
|
45
|
+
return { outcome: "trusted", endpoints };
|
|
46
|
+
}
|
|
47
|
+
/** Resolve endpoints, translating an SSRF/scheme refusal into a coded error. */
|
|
48
|
+
async function resolveEndpointsOrBlocked(servers, resolveHost) {
|
|
49
|
+
try {
|
|
50
|
+
return await resolveMcpEndpoints(servers, resolveHost);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof BlockedHostError) {
|
|
54
|
+
// Find the offending url server for the coded error's server name.
|
|
55
|
+
const offending = Object.keys(servers).find((id) => servers[id].url) ?? "url server";
|
|
56
|
+
throw mcpBlocked(offending, err.message);
|
|
57
|
+
}
|
|
58
|
+
throw err; // HostUnresolvedError → surfaced by the caller as a connect failure
|
|
21
59
|
}
|
|
22
|
-
return "declined";
|
|
23
60
|
}
|
|
24
61
|
/** The explicit escalation disclosure shown before trusting any server. */
|
|
25
|
-
function disclosure(names, color) {
|
|
62
|
+
function disclosure(names, servers, color, endpointChanged) {
|
|
26
63
|
const t = themeForColor(color);
|
|
27
|
-
const list = names
|
|
28
|
-
|
|
64
|
+
const list = names
|
|
65
|
+
.map((n) => ` • ${n} ${t.muted(describeTransport(servers[n]))}`)
|
|
66
|
+
.join("\n");
|
|
67
|
+
const hasUrl = names.some((n) => servers[n].url);
|
|
68
|
+
const hasStdio = names.some((n) => servers[n].command);
|
|
69
|
+
const lines = [
|
|
29
70
|
`${t.danger(t.strong("! MCP servers want to connect"))} ${t.muted(`(${names.length})`)}`,
|
|
30
71
|
list,
|
|
31
72
|
"",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
t.strong("
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"",
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
];
|
|
74
|
+
if (endpointChanged) {
|
|
75
|
+
lines.push(t.danger(t.strong(" A remote server's IP address set has CHANGED since you trusted it.")), t.muted(" The endpoint behind the URL may have moved — re-confirm before connecting."), "");
|
|
76
|
+
}
|
|
77
|
+
if (hasStdio) {
|
|
78
|
+
lines.push(t.strong(" Trusting a stdio server runs its code on your machine with your FULL"), t.strong(" privileges — it is NOT sandboxed, just like a program you ran yourself."));
|
|
79
|
+
}
|
|
80
|
+
if (hasUrl) {
|
|
81
|
+
lines.push(t.strong(" For a url server, cruxy sends your tool arguments to a REMOTE endpoint"), t.strong(" over the network (https, cert-validated, pinned to a public IP)."), t.muted(" Note: trusting a remote endpoint is WEAKER than a local binary — it can"), t.muted(" change its behavior with no change you can see. Only its URL + current IP"), t.muted(" set are bound to this decision."));
|
|
82
|
+
// Credential disclosure (C.27c): a server that RECEIVES a credential is a
|
|
83
|
+
// materially bigger grant — name it explicitly so the grant is never silent.
|
|
84
|
+
const credLines = names
|
|
85
|
+
.filter((n) => servers[n].credentialRef || servers[n].headers)
|
|
86
|
+
.map((n) => {
|
|
87
|
+
const ref = servers[n].credentialRef;
|
|
88
|
+
const what = ref
|
|
89
|
+
? `your "${ref}" credential`
|
|
90
|
+
: "your configured auth headers";
|
|
91
|
+
return t.danger(t.strong(` • "${n}" will RECEIVE ${what} on every request.`));
|
|
92
|
+
});
|
|
93
|
+
if (credLines.length)
|
|
94
|
+
lines.push("", ...credLines);
|
|
95
|
+
}
|
|
96
|
+
lines.push(t.muted(" Their tools are still individually approved before each call, and their"), t.muted(" output is treated as untrusted data."), "", ` ${t.muted("Trust and connect these servers for this repo?")} ${t.strong("[y/N]")} `);
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
/** A short "(stdio: …)" / "(url: …)" descriptor for the disclosure list. */
|
|
100
|
+
function describeTransport(cfg) {
|
|
101
|
+
if (cfg.command) {
|
|
102
|
+
return `(stdio: ${[cfg.command, ...(cfg.args ?? [])].join(" ")})`;
|
|
103
|
+
}
|
|
104
|
+
return cfg.url ? `(url: ${cfg.url})` : "(no transport)";
|
|
40
105
|
}
|
package/dist/mcp/trust.d.ts
CHANGED
|
@@ -21,14 +21,24 @@ export declare function mcpTrustPath(): string;
|
|
|
21
21
|
* construction so a benign reformat of the config (reindent, reordered keys)
|
|
22
22
|
* does NOT change it, while any real change to what would be executed DOES:
|
|
23
23
|
* - only the meaning-bearing fields are hashed (server id, command, args, url,
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* env as sorted key=value pairs, the auth `credentialRef`, and the NAMES of any
|
|
25
|
+
* raw auth headers — never a secret value: the token/header VALUE is never
|
|
26
|
+
* hashed, so rotating a credential does NOT re-gate, but SWAPPING which
|
|
27
|
+
* credential (or header) a server sends DOES — defeating trust-then-swap-cred);
|
|
28
|
+
* - args/env/header-names are normalized to a fixed order;
|
|
26
29
|
* - servers are sorted by id and serialized with a fixed field order.
|
|
27
30
|
*
|
|
28
31
|
* The empty set has a fixed, stable fingerprint (trusting "no servers" is
|
|
29
32
|
* meaningful; adding the first server re-gates).
|
|
30
33
|
*/
|
|
31
34
|
export declare function fingerprintMcpServers(servers: Record<string, McpServerConfig>): string;
|
|
35
|
+
/**
|
|
36
|
+
* Do two url-server endpoint maps bind the SAME address sets? (JC-D.) Both sides
|
|
37
|
+
* are expected pre-sorted/deduped (`normalizeAddressSet`); a differing server set
|
|
38
|
+
* or any differing address list is a mismatch → trust is stale → re-gate. The
|
|
39
|
+
* empty map equals the empty map (a stdio-only config has no endpoints to drift).
|
|
40
|
+
*/
|
|
41
|
+
export declare function endpointsMatch(a?: Record<string, string[]>, b?: Record<string, string[]>): boolean;
|
|
32
42
|
/** The persisted trust seam — file-backed in production, injectable for tests. */
|
|
33
43
|
export interface McpTrustStore {
|
|
34
44
|
/** The recorded decision for a repo root, or undefined if never trusted. */
|
package/dist/mcp/trust.js
CHANGED
|
@@ -26,8 +26,11 @@ export function mcpTrustPath() {
|
|
|
26
26
|
* construction so a benign reformat of the config (reindent, reordered keys)
|
|
27
27
|
* does NOT change it, while any real change to what would be executed DOES:
|
|
28
28
|
* - only the meaning-bearing fields are hashed (server id, command, args, url,
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* env as sorted key=value pairs, the auth `credentialRef`, and the NAMES of any
|
|
30
|
+
* raw auth headers — never a secret value: the token/header VALUE is never
|
|
31
|
+
* hashed, so rotating a credential does NOT re-gate, but SWAPPING which
|
|
32
|
+
* credential (or header) a server sends DOES — defeating trust-then-swap-cred);
|
|
33
|
+
* - args/env/header-names are normalized to a fixed order;
|
|
31
34
|
* - servers are sorted by id and serialized with a fixed field order.
|
|
32
35
|
*
|
|
33
36
|
* The empty set has a fixed, stable fingerprint (trusting "no servers" is
|
|
@@ -43,10 +46,31 @@ export function fingerprintMcpServers(servers) {
|
|
|
43
46
|
Object.entries(s.env ?? {})
|
|
44
47
|
.map(([k, v]) => `${k}=${v}`)
|
|
45
48
|
.sort(),
|
|
49
|
+
s.credentialRef ?? "",
|
|
50
|
+
// Header NAMES only — never the secret values (see fn doc: rotation must
|
|
51
|
+
// not re-gate; a changed set of headers sent must).
|
|
52
|
+
Object.keys(s.headers ?? {}).sort(),
|
|
46
53
|
])
|
|
47
54
|
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
48
55
|
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
49
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Do two url-server endpoint maps bind the SAME address sets? (JC-D.) Both sides
|
|
59
|
+
* are expected pre-sorted/deduped (`normalizeAddressSet`); a differing server set
|
|
60
|
+
* or any differing address list is a mismatch → trust is stale → re-gate. The
|
|
61
|
+
* empty map equals the empty map (a stdio-only config has no endpoints to drift).
|
|
62
|
+
*/
|
|
63
|
+
export function endpointsMatch(a = {}, b = {}) {
|
|
64
|
+
const ka = Object.keys(a).sort();
|
|
65
|
+
const kb = Object.keys(b).sort();
|
|
66
|
+
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i]))
|
|
67
|
+
return false;
|
|
68
|
+
return ka.every((k) => {
|
|
69
|
+
const av = a[k];
|
|
70
|
+
const bv = b[k];
|
|
71
|
+
return av.length === bv.length && av.every((x, i) => x === bv[i]);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
50
74
|
/**
|
|
51
75
|
* Is this repo's current MCP server config trusted? True only when a decision
|
|
52
76
|
* exists AND its fingerprint matches the current one — a changed config is
|
package/dist/mcp/types.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export interface McpTrust {
|
|
|
17
17
|
fingerprint: string;
|
|
18
18
|
/** ISO timestamp the decision was recorded. */
|
|
19
19
|
at: string;
|
|
20
|
+
/**
|
|
21
|
+
* Network (`url`) servers only (C.27b, JC-D): the sorted, deduped IP-address set
|
|
22
|
+
* each url server resolved to AT TRUST TIME. Bound into the decision so that if a
|
|
23
|
+
* remote endpoint's address set later changes, trust goes stale and re-gates —
|
|
24
|
+
* the network analog of the C.19 command-swap check. Absent/`{}` for a stdio-only
|
|
25
|
+
* config. HONEST LIMIT: this is WEAKER than stdio's binary fingerprint — a remote
|
|
26
|
+
* service can change its behavior with no observable change to URL or IP set. That
|
|
27
|
+
* is inherent to trusting a remote endpoint; it is disclosed, not papered over.
|
|
28
|
+
*/
|
|
29
|
+
endpoints?: Record<string, string[]>;
|
|
20
30
|
}
|
|
21
31
|
/**
|
|
22
32
|
* The transport seam — JSON-RPC over some duplex channel (stdio in production).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpServerConfig } from "../config/index.js";
|
|
2
|
+
import { type HostResolver } from "../net/ip-guard.js";
|
|
3
|
+
/**
|
|
4
|
+
* The network-MCP URL policy (C.27b, JC-E). A `url` MCP server is a NEW trust
|
|
5
|
+
* boundary stdio never had: the endpoint is off-box and reached over a wire we do
|
|
6
|
+
* not control. This module is the ONE place a URL is turned into a validated,
|
|
7
|
+
* pin-ready address set, so the scheme + SSRF rules are enforced by construction
|
|
8
|
+
* and can be audited in one spot. It reuses the shared {@link ../net/ip-guard}
|
|
9
|
+
* range math (JC-A) — it does NOT re-implement any address checks.
|
|
10
|
+
*
|
|
11
|
+
* Policy:
|
|
12
|
+
* - `https://` → every resolved address MUST be public (SSRF gate); the socket is
|
|
13
|
+
* later pinned to exactly those addresses so a rebind can't flip check→connect.
|
|
14
|
+
* TLS certificate validation is undici's default and is NEVER disabled (there is
|
|
15
|
+
* deliberately no skip-verify option — it is the kind of footgun that ends up set
|
|
16
|
+
* in prod).
|
|
17
|
+
* - `http://` → permitted ONLY when every resolved address is loopback (a local
|
|
18
|
+
* dev server). Plaintext to any non-loopback host is refused — use `https`.
|
|
19
|
+
* - anything else (`file:`, `ws:`, `data:`…) → refused.
|
|
20
|
+
*
|
|
21
|
+
* A refusal throws {@link BlockedHostError}; an unresolvable host throws
|
|
22
|
+
* {@link HostUnresolvedError}. The service layer maps these to coded errors
|
|
23
|
+
* (`CRUXY_E_MCP_BLOCKED` / `CRUXY_E_MCP_CONNECT`) — never a silent skip.
|
|
24
|
+
*/
|
|
25
|
+
export interface ValidatedMcpUrl {
|
|
26
|
+
/** The parsed URL (host header / TLS SNI still carry this hostname). */
|
|
27
|
+
url: URL;
|
|
28
|
+
/** The validated, sorted, deduped address set to pin the connection to. */
|
|
29
|
+
addresses: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Validate one MCP server URL against the scheme + SSRF policy and return the
|
|
33
|
+
* address set to pin to. Resolution goes through the injected {@link HostResolver}
|
|
34
|
+
* so tests are deterministic and so the SAME resolved set can feed both the trust
|
|
35
|
+
* fingerprint (JC-D) and the connection pin (no second, rebind-able resolve).
|
|
36
|
+
*/
|
|
37
|
+
export declare function validateMcpUrl(rawUrl: string, resolve: HostResolver): Promise<ValidatedMcpUrl>;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve + validate every `url` server's endpoint, returning a map of server id
|
|
40
|
+
* → sorted address set. stdio servers are skipped (they have no network endpoint).
|
|
41
|
+
* Throws {@link BlockedHostError}/{@link HostUnresolvedError} on the first refusal
|
|
42
|
+
* — the caller records trust / opens a socket only if ALL url servers validate.
|
|
43
|
+
*
|
|
44
|
+
* This is the single resolution used for BOTH the JC-D fingerprint (the IP set is
|
|
45
|
+
* bound into the trust decision) and the connection pin, so the addresses trusted
|
|
46
|
+
* are exactly the addresses dialed.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveMcpEndpoints(servers: Record<string, McpServerConfig>, resolve: HostResolver): Promise<Record<string, string[]>>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { assertAllPublic, BlockedHostError, HostUnresolvedError, isLoopbackAddress, normalizeAddressSet, } from "../net/ip-guard.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate one MCP server URL against the scheme + SSRF policy and return the
|
|
4
|
+
* address set to pin to. Resolution goes through the injected {@link HostResolver}
|
|
5
|
+
* so tests are deterministic and so the SAME resolved set can feed both the trust
|
|
6
|
+
* fingerprint (JC-D) and the connection pin (no second, rebind-able resolve).
|
|
7
|
+
*/
|
|
8
|
+
export async function validateMcpUrl(rawUrl, resolve) {
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(rawUrl);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new BlockedHostError(`"${rawUrl}" is not a valid absolute URL`);
|
|
15
|
+
}
|
|
16
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
17
|
+
if (url.protocol === "https:") {
|
|
18
|
+
const addresses = await assertAllPublic(host, resolve);
|
|
19
|
+
return { url, addresses: normalizeAddressSet(addresses) };
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol === "http:") {
|
|
22
|
+
let addresses;
|
|
23
|
+
try {
|
|
24
|
+
addresses = await resolve(host);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
throw new HostUnresolvedError(`could not resolve host "${host}": ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
if (addresses.length === 0) {
|
|
30
|
+
throw new HostUnresolvedError(`host "${host}" resolved to no addresses`);
|
|
31
|
+
}
|
|
32
|
+
for (const addr of addresses) {
|
|
33
|
+
if (!isLoopbackAddress(addr)) {
|
|
34
|
+
throw new BlockedHostError(`http MCP URL "${rawUrl}" resolves to ${addr}; plaintext http is only ` +
|
|
35
|
+
`permitted for loopback dev servers — use https for a remote server`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { url, addresses: normalizeAddressSet(addresses) };
|
|
39
|
+
}
|
|
40
|
+
throw new BlockedHostError(`an MCP server URL must be https (or http to a loopback dev server); ` +
|
|
41
|
+
`got "${url.protocol.replace(/:$/, "")}"`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve + validate every `url` server's endpoint, returning a map of server id
|
|
45
|
+
* → sorted address set. stdio servers are skipped (they have no network endpoint).
|
|
46
|
+
* Throws {@link BlockedHostError}/{@link HostUnresolvedError} on the first refusal
|
|
47
|
+
* — the caller records trust / opens a socket only if ALL url servers validate.
|
|
48
|
+
*
|
|
49
|
+
* This is the single resolution used for BOTH the JC-D fingerprint (the IP set is
|
|
50
|
+
* bound into the trust decision) and the connection pin, so the addresses trusted
|
|
51
|
+
* are exactly the addresses dialed.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveMcpEndpoints(servers, resolve) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [id, cfg] of Object.entries(servers)) {
|
|
56
|
+
if (!cfg.url)
|
|
57
|
+
continue;
|
|
58
|
+
const { addresses } = await validateMcpUrl(cfg.url, resolve);
|
|
59
|
+
out[id] = addresses;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE owner of "is this IP allowed to be reached" (JC-A). The SSRF address
|
|
3
|
+
* math — parsing and range-classifying v4/v6 literals, including IPv4-mapped and
|
|
4
|
+
* alternate encodings — lives here and nowhere else. Both the web subsystem
|
|
5
|
+
* (`web/ssrf.ts`, C.20) and the network MCP transport (`mcp/http-transport.ts`,
|
|
6
|
+
* C.27b) consume it, so there is exactly one implementation of the range rules
|
|
7
|
+
* and one place to audit them.
|
|
8
|
+
*
|
|
9
|
+
* The two connection-facing helpers a caller needs are also here:
|
|
10
|
+
* - {@link assertAllPublic} — resolve a host and require EVERY address be public;
|
|
11
|
+
* - {@link pinnedLookup} — a `dns.lookup` shim that pins a connection to a set of
|
|
12
|
+
* already-validated addresses (defeats DNS rebinding). Each subsystem wraps it
|
|
13
|
+
* in its OWN undici `Agent` (the web one-shot fetch vs. MCP's long-lived
|
|
14
|
+
* POST + SSE shape differ), but the pinning logic is shared.
|
|
15
|
+
*/
|
|
16
|
+
/** Resolve a hostname to its IP addresses (injected so the guard is testable). */
|
|
17
|
+
export type HostResolver = (host: string) => Promise<string[]>;
|
|
18
|
+
/** Default resolver: node's `dns.lookup` returning ALL addresses. */
|
|
19
|
+
export declare const defaultResolveHost: HostResolver;
|
|
20
|
+
/** Thrown when a URL/host is refused pre-dispatch; carries a human reason. */
|
|
21
|
+
export declare class BlockedHostError extends Error {
|
|
22
|
+
}
|
|
23
|
+
/** Thrown when the host could not be resolved (a network failure, not a block). */
|
|
24
|
+
export declare class HostUnresolvedError extends Error {
|
|
25
|
+
}
|
|
26
|
+
/** True if an address (v4 or v6) is in a range that must never be reached. */
|
|
27
|
+
export declare function isBlockedAddress(addr: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* True if an address is a LOOPBACK address (127.0.0.0/8, ::1, or an IPv4-mapped
|
|
30
|
+
* loopback). This is the ONLY range the network MCP transport permits over plain
|
|
31
|
+
* `http` (loopback dev servers); everything else must be `https` to a public IP.
|
|
32
|
+
* An unparseable address is not loopback (fail closed — it won't get the http pass).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isLoopbackAddress(addr: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `host` and require that EVERY resolved address is public — the SSRF
|
|
37
|
+
* gate for any URL a caller did not fully control. Throws {@link BlockedHostError}
|
|
38
|
+
* if any address is private/loopback/link-local/reserved (a security refusal), or
|
|
39
|
+
* {@link HostUnresolvedError} if the host cannot be resolved (a network failure).
|
|
40
|
+
* Returns the validated addresses so the caller can PIN the connection to them.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertAllPublic(host: string, resolve: HostResolver): Promise<string[]>;
|
|
43
|
+
/**
|
|
44
|
+
* A `dns.lookup`-compatible function that ignores the hostname and always hands
|
|
45
|
+
* back one of the pre-validated `addresses`. This is what pins a connection to the
|
|
46
|
+
* address the SSRF check already approved, defeating DNS rebinding: the socket can
|
|
47
|
+
* only reach a validated IP, never a value re-resolved at connect time. The Host
|
|
48
|
+
* header / TLS SNI still carry the original hostname (only the dialed IP is pinned).
|
|
49
|
+
*/
|
|
50
|
+
export declare function pinnedLookup(addresses: string[]): (_hostname: string, options: unknown, callback: (err: NodeJS.ErrnoException | null, address: string | {
|
|
51
|
+
address: string;
|
|
52
|
+
family: number;
|
|
53
|
+
}[], family?: number) => void) => void;
|
|
54
|
+
/** Sort + dedupe an address set so equality checks are order-insensitive. */
|
|
55
|
+
export declare function normalizeAddressSet(addresses: string[]): string[];
|