@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.
Files changed (54) hide show
  1. package/dist/approval/prompt.d.ts +7 -1
  2. package/dist/approval/prompt.js +52 -17
  3. package/dist/cli/commands/mcp.js +106 -7
  4. package/dist/cli/commands/skills.js +10 -2
  5. package/dist/cli/repl.js +9 -3
  6. package/dist/components/frame.d.ts +6 -3
  7. package/dist/components/frame.js +21 -23
  8. package/dist/components/fuzzy.js +5 -1
  9. package/dist/components/select.js +4 -1
  10. package/dist/config/credentials.d.ts +9 -0
  11. package/dist/config/credentials.js +29 -1
  12. package/dist/config/manager.js +30 -3
  13. package/dist/config/schema.d.ts +182 -8
  14. package/dist/config/schema.js +43 -5
  15. package/dist/errors/constructors.d.ts +29 -0
  16. package/dist/errors/constructors.js +69 -0
  17. package/dist/errors/types.d.ts +15 -0
  18. package/dist/errors/types.js +18 -0
  19. package/dist/mcp/http-transport.d.ts +89 -0
  20. package/dist/mcp/http-transport.js +299 -0
  21. package/dist/mcp/index.d.ts +4 -2
  22. package/dist/mcp/index.js +3 -1
  23. package/dist/mcp/service.d.ts +19 -2
  24. package/dist/mcp/service.js +92 -20
  25. package/dist/mcp/trust-gate.d.ts +35 -11
  26. package/dist/mcp/trust-gate.js +87 -22
  27. package/dist/mcp/trust.d.ts +12 -2
  28. package/dist/mcp/trust.js +26 -2
  29. package/dist/mcp/types.d.ts +10 -0
  30. package/dist/mcp/url-guard.d.ts +48 -0
  31. package/dist/mcp/url-guard.js +62 -0
  32. package/dist/net/ip-guard.d.ts +55 -0
  33. package/dist/net/ip-guard.js +229 -0
  34. package/dist/render/capabilities.d.ts +11 -0
  35. package/dist/render/capabilities.js +19 -3
  36. package/dist/render/diff.d.ts +1 -1
  37. package/dist/render/diff.js +23 -7
  38. package/dist/render/index.d.ts +5 -2
  39. package/dist/render/index.js +9 -2
  40. package/dist/render/layout.d.ts +59 -0
  41. package/dist/render/layout.js +158 -0
  42. package/dist/render/motion.d.ts +76 -0
  43. package/dist/render/motion.js +94 -0
  44. package/dist/render/resize.d.ts +36 -0
  45. package/dist/render/resize.js +45 -0
  46. package/dist/render/state.d.ts +13 -0
  47. package/dist/render/state.js +38 -0
  48. package/dist/render/tty-renderer.d.ts +25 -3
  49. package/dist/render/tty-renderer.js +94 -32
  50. package/dist/render/types.d.ts +15 -1
  51. package/dist/web/ssrf.d.ts +8 -22
  52. package/dist/web/ssrf.js +11 -183
  53. package/dist/web/types.d.ts +4 -2
  54. package/package.json +1 -1
@@ -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[];
@@ -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