@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.
Files changed (40) hide show
  1. package/dist/cli/commands/mcp.js +106 -7
  2. package/dist/config/credentials.d.ts +9 -0
  3. package/dist/config/credentials.js +29 -1
  4. package/dist/config/manager.js +30 -3
  5. package/dist/config/schema.d.ts +182 -8
  6. package/dist/config/schema.js +43 -5
  7. package/dist/errors/constructors.d.ts +29 -0
  8. package/dist/errors/constructors.js +69 -0
  9. package/dist/errors/types.d.ts +15 -0
  10. package/dist/errors/types.js +18 -0
  11. package/dist/mcp/http-transport.d.ts +89 -0
  12. package/dist/mcp/http-transport.js +299 -0
  13. package/dist/mcp/index.d.ts +4 -2
  14. package/dist/mcp/index.js +3 -1
  15. package/dist/mcp/service.d.ts +19 -2
  16. package/dist/mcp/service.js +92 -20
  17. package/dist/mcp/trust-gate.d.ts +35 -11
  18. package/dist/mcp/trust-gate.js +87 -22
  19. package/dist/mcp/trust.d.ts +12 -2
  20. package/dist/mcp/trust.js +26 -2
  21. package/dist/mcp/types.d.ts +10 -0
  22. package/dist/mcp/url-guard.d.ts +48 -0
  23. package/dist/mcp/url-guard.js +62 -0
  24. package/dist/net/ip-guard.d.ts +55 -0
  25. package/dist/net/ip-guard.js +229 -0
  26. package/dist/render/index.d.ts +1 -0
  27. package/dist/render/index.js +1 -0
  28. package/dist/render/motion.d.ts +76 -0
  29. package/dist/render/motion.js +94 -0
  30. package/dist/render/tty-renderer.d.ts +17 -3
  31. package/dist/render/tty-renderer.js +58 -21
  32. package/dist/tools/file/apply-patch.js +12 -8
  33. package/dist/tools/file/edit-file.d.ts +0 -2
  34. package/dist/tools/file/edit-file.js +10 -19
  35. package/dist/tools/file/match.d.ts +43 -0
  36. package/dist/tools/file/match.js +127 -0
  37. package/dist/web/ssrf.d.ts +8 -22
  38. package/dist/web/ssrf.js +11 -183
  39. package/dist/web/types.d.ts +4 -2
  40. package/package.json +1 -1
@@ -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) => McpTransport;
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
@@ -1,7 +1,9 @@
1
1
  import path from "node:path";
2
- import { mcpConnect } from "../errors/index.js";
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 decline connects to
21
- // nothing.
22
- const outcome = await ensureMcpTrust(root, servers, {
23
- store: deps?.trustStore ?? fileMcpTrustStore(),
24
- interactive,
25
- io,
26
- now: deps?.now,
27
- });
28
- if (outcome === "declined") {
29
- logger.info("mcp: servers not trusted — no MCP tools were loaded");
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
- const transport = makeTransport(server, cfg, root, deps);
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 (only stdio \`command\` is supported)`);
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
- const coded = mcpConnect(server, err);
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
- /** Build the real stdio transport for a server, or null for an unsupported one. */
72
- function makeTransport(server, cfg, root, deps) {
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 (!cfg.command)
76
- return null; // url transport is not yet supported
77
- return new McpStdioTransport({ command: cfg.command, args: cfg.args ?? [], env: cfg.env ?? {} }, root);
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
@@ -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). 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".
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 (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.
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 anything is spawned. Non-interactive NEVER auto-trusts.
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 declare function ensureMcpTrust(root: string, servers: Record<string, McpServerConfig>, deps: EnsureMcpTrustDeps): Promise<McpTrustOutcome>;
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>;
@@ -1,40 +1,105 @@
1
- import { mcpUntrusted } from "../errors/index.js";
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 { fingerprintMcpServers, isMcpTrusted, } from "./trust.js";
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
- if (isMcpTrusted(deps.store, root, fingerprint))
8
- return "trusted";
9
- // Non-interactive: fail closed, before any spawn. Never auto-trust.
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 === "y") {
18
- const now = deps.now ?? (() => new Date().toISOString());
19
- deps.store.record({ root, fingerprint, at: now() });
20
- return "trusted";
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.map((n) => ` • ${n}`).join("\n");
28
- return [
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
- 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");
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
  }
@@ -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
- * and env as sorted key=value pairs);
25
- * - args/env are normalized to a fixed order;
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
- * and env as sorted key=value pairs);
30
- * - args/env are normalized to a fixed order;
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
@@ -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[];