@cruxy/cli 0.25.0 → 0.27.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/prompt.d.ts +7 -1
- package/dist/approval/prompt.js +52 -17
- package/dist/cli/commands/mcp.js +106 -7
- package/dist/cli/commands/skills.js +10 -2
- package/dist/cli/repl.js +9 -3
- package/dist/components/frame.d.ts +6 -3
- package/dist/components/frame.js +21 -23
- package/dist/components/fuzzy.js +5 -1
- package/dist/components/select.js +4 -1
- 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/capabilities.d.ts +11 -0
- package/dist/render/capabilities.js +19 -3
- package/dist/render/diff.d.ts +1 -1
- package/dist/render/diff.js +23 -7
- package/dist/render/index.d.ts +5 -2
- package/dist/render/index.js +9 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -0
- package/dist/render/resize.d.ts +36 -0
- package/dist/render/resize.js +45 -0
- package/dist/render/state.d.ts +13 -0
- package/dist/render/state.js +38 -0
- package/dist/render/tty-renderer.d.ts +25 -3
- package/dist/render/tty-renderer.js +94 -32
- package/dist/render/types.d.ts +15 -1
- 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/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[];
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { lookup } from "node:dns";
|
|
2
|
+
/** Default resolver: node's `dns.lookup` returning ALL addresses. */
|
|
3
|
+
export const defaultResolveHost = (host) => new Promise((resolve, reject) => {
|
|
4
|
+
lookup(host, { all: true }, (err, addresses) => {
|
|
5
|
+
if (err)
|
|
6
|
+
reject(err);
|
|
7
|
+
else
|
|
8
|
+
resolve(addresses.map((a) => a.address));
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
/** Thrown when a URL/host is refused pre-dispatch; carries a human reason. */
|
|
12
|
+
export class BlockedHostError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** Thrown when the host could not be resolved (a network failure, not a block). */
|
|
15
|
+
export class HostUnresolvedError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/** Parse a dotted-quad IPv4 string into its four octets, or null. */
|
|
18
|
+
function parseIpv4(ip) {
|
|
19
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
const octets = m.slice(1, 5).map((s) => Number(s));
|
|
23
|
+
if (octets.some((o) => o > 255))
|
|
24
|
+
return null;
|
|
25
|
+
return octets;
|
|
26
|
+
}
|
|
27
|
+
/** True if an IPv4 address falls in a private/loopback/link-local/reserved range. */
|
|
28
|
+
function isBlockedIpv4(ip) {
|
|
29
|
+
const octets = parseIpv4(ip);
|
|
30
|
+
if (!octets)
|
|
31
|
+
return false;
|
|
32
|
+
const [a, b] = octets;
|
|
33
|
+
if (a === 0)
|
|
34
|
+
return true; // 0.0.0.0/8 "this network" / unspecified
|
|
35
|
+
if (a === 10)
|
|
36
|
+
return true; // 10.0.0.0/8 private
|
|
37
|
+
if (a === 127)
|
|
38
|
+
return true; // 127.0.0.0/8 loopback
|
|
39
|
+
if (a === 169 && b === 254)
|
|
40
|
+
return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
|
41
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
42
|
+
return true; // 172.16.0.0/12 private
|
|
43
|
+
if (a === 192 && b === 168)
|
|
44
|
+
return true; // 192.168.0.0/16 private
|
|
45
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
46
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
47
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
48
|
+
return true; // 198.18.0.0/15 benchmarking
|
|
49
|
+
if (a === 255 && b === 255)
|
|
50
|
+
return true; // broadcast-ish
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Expand an IPv6 literal into its 8 sixteen-bit groups, or null if unparseable.
|
|
55
|
+
* Handles `::` compression and an embedded IPv4 tail (`::ffff:127.0.0.1`).
|
|
56
|
+
*/
|
|
57
|
+
function parseIpv6(input) {
|
|
58
|
+
let s = input;
|
|
59
|
+
const tail = [];
|
|
60
|
+
// Peel off a trailing dotted-quad (IPv4-mapped/-compatible forms).
|
|
61
|
+
const v4 = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
|
|
62
|
+
if (v4) {
|
|
63
|
+
const o = parseIpv4(v4[1]);
|
|
64
|
+
if (!o)
|
|
65
|
+
return null;
|
|
66
|
+
tail.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
|
|
67
|
+
s = s.slice(0, v4.index); // leaves a trailing ':' before the compression split
|
|
68
|
+
}
|
|
69
|
+
const halves = s.split("::");
|
|
70
|
+
if (halves.length > 2)
|
|
71
|
+
return null; // more than one "::" is illegal
|
|
72
|
+
const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
|
|
73
|
+
const rest = halves[1] ? halves[1].split(":").filter(Boolean) : [];
|
|
74
|
+
const toNums = (groups) => {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const g of groups) {
|
|
77
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
78
|
+
return null;
|
|
79
|
+
out.push(parseInt(g, 16));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
const headNums = toNums(head);
|
|
84
|
+
const restNums = toNums(rest);
|
|
85
|
+
if (!headNums || !restNums)
|
|
86
|
+
return null;
|
|
87
|
+
let groups;
|
|
88
|
+
if (halves.length === 2) {
|
|
89
|
+
const fill = 8 - (headNums.length + restNums.length + tail.length);
|
|
90
|
+
if (fill < 0)
|
|
91
|
+
return null;
|
|
92
|
+
groups = [
|
|
93
|
+
...headNums,
|
|
94
|
+
...Array(fill).fill(0),
|
|
95
|
+
...restNums,
|
|
96
|
+
...tail,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
groups = [...headNums, ...tail];
|
|
101
|
+
}
|
|
102
|
+
return groups.length === 8 ? groups : null;
|
|
103
|
+
}
|
|
104
|
+
/** The dotted-quad embedded in an IPv6 tail's last two groups. */
|
|
105
|
+
function embeddedV4(g) {
|
|
106
|
+
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
|
107
|
+
}
|
|
108
|
+
/** True if an expanded IPv6 address is in a range that must never be reached. */
|
|
109
|
+
function isBlockedIpv6(g) {
|
|
110
|
+
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): check the v4 part.
|
|
111
|
+
const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
|
|
112
|
+
const firstSixZero = firstFiveZero && g[5] === 0;
|
|
113
|
+
const embedded = embeddedV4(g);
|
|
114
|
+
if (firstFiveZero && g[5] === 0xffff)
|
|
115
|
+
return isBlockedIpv4(embedded); // ::ffff:x
|
|
116
|
+
if (firstSixZero &&
|
|
117
|
+
!(g[6] === 0 && g[7] === 0) &&
|
|
118
|
+
!(g[6] === 0 && g[7] === 1))
|
|
119
|
+
return isBlockedIpv4(embedded); // ::x.y.z.w (IPv4-compatible, deprecated)
|
|
120
|
+
if (g.every((x) => x === 0))
|
|
121
|
+
return true; // :: unspecified
|
|
122
|
+
if (firstSixZero && g[6] === 0 && g[7] === 1)
|
|
123
|
+
return true; // ::1 loopback
|
|
124
|
+
if ((g[0] & 0xffc0) === 0xfe80)
|
|
125
|
+
return true; // fe80::/10 link-local (fe80–febf)
|
|
126
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
127
|
+
return true; // fc00::/7 unique-local (fc00–fdff)
|
|
128
|
+
if ((g[0] & 0xff00) === 0xff00)
|
|
129
|
+
return true; // ff00::/8 multicast
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
/** Strip brackets, lowercase, and drop an IPv6 scope/zone id from an address. */
|
|
133
|
+
function normalizeAddress(addr) {
|
|
134
|
+
return addr
|
|
135
|
+
.trim()
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/^\[|\]$/g, "")
|
|
138
|
+
.split("%")[0];
|
|
139
|
+
}
|
|
140
|
+
/** True if an address (v4 or v6) is in a range that must never be reached. */
|
|
141
|
+
export function isBlockedAddress(addr) {
|
|
142
|
+
const ip = normalizeAddress(addr);
|
|
143
|
+
if (ip.includes(":")) {
|
|
144
|
+
const groups = parseIpv6(ip);
|
|
145
|
+
if (!groups)
|
|
146
|
+
return true; // fail closed: an unparseable colon-address is refused
|
|
147
|
+
return isBlockedIpv6(groups);
|
|
148
|
+
}
|
|
149
|
+
return isBlockedIpv4(ip);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* True if an address is a LOOPBACK address (127.0.0.0/8, ::1, or an IPv4-mapped
|
|
153
|
+
* loopback). This is the ONLY range the network MCP transport permits over plain
|
|
154
|
+
* `http` (loopback dev servers); everything else must be `https` to a public IP.
|
|
155
|
+
* An unparseable address is not loopback (fail closed — it won't get the http pass).
|
|
156
|
+
*/
|
|
157
|
+
export function isLoopbackAddress(addr) {
|
|
158
|
+
const ip = normalizeAddress(addr);
|
|
159
|
+
if (ip.includes(":")) {
|
|
160
|
+
const g = parseIpv6(ip);
|
|
161
|
+
if (!g)
|
|
162
|
+
return false;
|
|
163
|
+
const firstSixZero = g.slice(0, 6).every((x) => x === 0);
|
|
164
|
+
if (firstSixZero && g[6] === 0 && g[7] === 1)
|
|
165
|
+
return true; // ::1
|
|
166
|
+
// ::ffff:127.x and deprecated ::127.x embed a v4 loopback.
|
|
167
|
+
const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
|
|
168
|
+
if (firstFiveZero && (g[5] === 0xffff || g[5] === 0)) {
|
|
169
|
+
const embedded = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
|
170
|
+
const o = parseIpv4(embedded);
|
|
171
|
+
return o !== null && o[0] === 127;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const o = parseIpv4(ip);
|
|
176
|
+
return o !== null && o[0] === 127;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve `host` and require that EVERY resolved address is public — the SSRF
|
|
180
|
+
* gate for any URL a caller did not fully control. Throws {@link BlockedHostError}
|
|
181
|
+
* if any address is private/loopback/link-local/reserved (a security refusal), or
|
|
182
|
+
* {@link HostUnresolvedError} if the host cannot be resolved (a network failure).
|
|
183
|
+
* Returns the validated addresses so the caller can PIN the connection to them.
|
|
184
|
+
*/
|
|
185
|
+
export async function assertAllPublic(host, resolve) {
|
|
186
|
+
const cleaned = host.replace(/^\[|\]$/g, "");
|
|
187
|
+
let addresses;
|
|
188
|
+
try {
|
|
189
|
+
addresses = await resolve(cleaned);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
throw new HostUnresolvedError(`could not resolve host "${cleaned}": ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
if (addresses.length === 0) {
|
|
195
|
+
throw new HostUnresolvedError(`host "${cleaned}" resolved to no addresses`);
|
|
196
|
+
}
|
|
197
|
+
for (const addr of addresses) {
|
|
198
|
+
if (isBlockedAddress(addr)) {
|
|
199
|
+
throw new BlockedHostError(`host "${cleaned}" resolves to ${addr}, a private/loopback/link-local address`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return addresses;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* A `dns.lookup`-compatible function that ignores the hostname and always hands
|
|
206
|
+
* back one of the pre-validated `addresses`. This is what pins a connection to the
|
|
207
|
+
* address the SSRF check already approved, defeating DNS rebinding: the socket can
|
|
208
|
+
* only reach a validated IP, never a value re-resolved at connect time. The Host
|
|
209
|
+
* header / TLS SNI still carry the original hostname (only the dialed IP is pinned).
|
|
210
|
+
*/
|
|
211
|
+
export function pinnedLookup(addresses) {
|
|
212
|
+
const resolved = addresses.map((address) => ({
|
|
213
|
+
address,
|
|
214
|
+
family: address.includes(":") ? 6 : 4,
|
|
215
|
+
}));
|
|
216
|
+
return (_hostname, options, callback) => {
|
|
217
|
+
const all = typeof options === "object" && options !== null && "all" in options
|
|
218
|
+
? options.all
|
|
219
|
+
: false;
|
|
220
|
+
if (all)
|
|
221
|
+
callback(null, resolved);
|
|
222
|
+
else
|
|
223
|
+
callback(null, resolved[0].address, resolved[0].family);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Sort + dedupe an address set so equality checks are order-insensitive. */
|
|
227
|
+
export function normalizeAddressSet(addresses) {
|
|
228
|
+
return [...new Set(addresses.map(normalizeAddress))].sort();
|
|
229
|
+
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
|
|
3
|
+
export declare const DEFAULT_COLUMNS = 80;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the terminal width (U.12) — the single rule behind
|
|
6
|
+
* {@link RenderCapabilities.width} and every resize recompute. `COLUMNS` wins
|
|
7
|
+
* when set (honored so `COLUMNS=100 cruxy …` and CI overrides work), then the
|
|
8
|
+
* stream's own `columns`, then {@link DEFAULT_COLUMNS}. Never returns a
|
|
9
|
+
* non-positive width — an unknown terminal degrades to a sensible default, it
|
|
10
|
+
* does not crash a width calculation with 0.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveColumns(stream?: RenderStream, env?: NodeJS.ProcessEnv): number;
|
|
2
13
|
/**
|
|
3
14
|
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
4
15
|
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|