@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,16 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { resolveToolPath } from "./paths.js";
4
- /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
5
- export function countOccurrences(haystack, needle) {
6
- let count = 0;
7
- let i = haystack.indexOf(needle);
8
- while (i !== -1) {
9
- count++;
10
- i = haystack.indexOf(needle, i + needle.length);
11
- }
12
- return count;
13
- }
4
+ import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
14
5
  /**
15
6
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
16
7
  * The uniqueness requirement is checked before approval so the model can fix an
@@ -47,14 +38,14 @@ export const editFileTool = {
47
38
  }
48
39
  return { ok: false, error: err.message };
49
40
  }
50
- const matches = countOccurrences(content, input.old_str);
51
- if (matches === 0) {
41
+ const match = findMatch(content, input.old_str);
42
+ if (match.kind === "none") {
52
43
  return { ok: false, error: `old_str not found in ${input.path}` };
53
44
  }
54
- if (matches > 1) {
45
+ if (match.kind === "ambiguous") {
55
46
  return {
56
47
  ok: false,
57
- error: `old_str not unique (${matches} matches); add surrounding context to disambiguate`,
48
+ error: `old_str not unique (${match.count} matches${tierLabel(match.tier)}); add surrounding context to disambiguate`,
58
49
  };
59
50
  }
60
51
  const decision = await ctx.requestApproval({
@@ -68,11 +59,11 @@ export const editFileTool = {
68
59
  error: decision.feedback ?? `edit to ${input.path} denied`,
69
60
  };
70
61
  }
71
- // Replace the single occurrence by index to avoid `$`-pattern interpretation.
72
- const idx = content.indexOf(input.old_str);
73
- const updated = content.slice(0, idx) +
74
- input.new_str +
75
- content.slice(idx + input.old_str.length);
62
+ // Splice the matched span by offset (avoids `$`-pattern interpretation) and
63
+ // re-encode new_str to the file's line ending so a CRLF file stays CRLF.
64
+ const updated = content.slice(0, match.start) +
65
+ applyEol(input.new_str, detectEol(content)) +
66
+ content.slice(match.end);
76
67
  try {
77
68
  await fs.writeFile(abs, updated, "utf8");
78
69
  return { ok: true, output: `edited ${input.path}` };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Locating `old_str` inside a file for edit_file / apply_patch.
3
+ *
4
+ * Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
5
+ * checkouts/editors) — a byte-exact `indexOf` then never matches and the model
6
+ * can't replace existing code. We match in ordered tiers, tightest first, and
7
+ * stop at the first tier that finds anything:
8
+ *
9
+ * 0. exact — raw bytes; every currently-working edit takes this path
10
+ * 1. eol — CRLF and lone CR treated as LF (comparison only)
11
+ * 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
12
+ *
13
+ * Looser tiers compare against a normalized copy but return offsets into the
14
+ * ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
15
+ * file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
16
+ * more than one match at a tier is a hard error, and we do not fall through to a
17
+ * looser tier (looser can only be more ambiguous, never less).
18
+ */
19
+ export type Tier = "exact" | "eol" | "eol+trailws";
20
+ export type MatchResult = {
21
+ kind: "found";
22
+ start: number;
23
+ end: number;
24
+ } | {
25
+ kind: "ambiguous";
26
+ count: number;
27
+ tier: Tier;
28
+ } | {
29
+ kind: "none";
30
+ };
31
+ /**
32
+ * Find the single occurrence of `oldStr` in `content`, tolerating line-ending
33
+ * and trailing-whitespace differences. Returns original-string byte offsets on
34
+ * a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
35
+ * `none` if no tier matched.
36
+ */
37
+ export declare function findMatch(content: string, oldStr: string): MatchResult;
38
+ /** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
39
+ export declare function detectEol(content: string): "\r\n" | "\n";
40
+ /** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
41
+ export declare function applyEol(text: string, eol: "\r\n" | "\n"): string;
42
+ /** Human phrase for the tier named in an ambiguity error. */
43
+ export declare function tierLabel(tier: Tier): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Locating `old_str` inside a file for edit_file / apply_patch.
3
+ *
4
+ * Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
5
+ * checkouts/editors) — a byte-exact `indexOf` then never matches and the model
6
+ * can't replace existing code. We match in ordered tiers, tightest first, and
7
+ * stop at the first tier that finds anything:
8
+ *
9
+ * 0. exact — raw bytes; every currently-working edit takes this path
10
+ * 1. eol — CRLF and lone CR treated as LF (comparison only)
11
+ * 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
12
+ *
13
+ * Looser tiers compare against a normalized copy but return offsets into the
14
+ * ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
15
+ * file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
16
+ * more than one match at a tier is a hard error, and we do not fall through to a
17
+ * looser tier (looser can only be more ambiguous, never less).
18
+ */
19
+ const TIERS = ["exact", "eol", "eol+trailws"];
20
+ /**
21
+ * Find the single occurrence of `oldStr` in `content`, tolerating line-ending
22
+ * and trailing-whitespace differences. Returns original-string byte offsets on
23
+ * a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
24
+ * `none` if no tier matched.
25
+ */
26
+ export function findMatch(content, oldStr) {
27
+ for (const tier of TIERS) {
28
+ const occ = occurrences(content, oldStr, tier);
29
+ if (occ.length === 1) {
30
+ return { kind: "found", start: occ[0].start, end: occ[0].end };
31
+ }
32
+ if (occ.length > 1) {
33
+ return { kind: "ambiguous", count: occ.length, tier };
34
+ }
35
+ // 0 matches → try the next, looser tier.
36
+ }
37
+ return { kind: "none" };
38
+ }
39
+ /** Non-overlapping occurrences of `oldStr` under `tier`, as original offsets. */
40
+ function occurrences(content, oldStr, tier) {
41
+ const out = [];
42
+ if (tier === "exact") {
43
+ let i = content.indexOf(oldStr);
44
+ while (i !== -1) {
45
+ out.push({ start: i, end: i + oldStr.length });
46
+ i = content.indexOf(oldStr, i + oldStr.length);
47
+ }
48
+ return out;
49
+ }
50
+ const { norm, map } = normalize(content, tier);
51
+ const { norm: needle } = normalize(oldStr, tier);
52
+ // An all-whitespace old_str can normalize to empty under eol+trailws; refuse
53
+ // to "match everywhere" rather than delete at an arbitrary point.
54
+ if (needle.length === 0)
55
+ return out;
56
+ let i = norm.indexOf(needle);
57
+ while (i !== -1) {
58
+ out.push({ start: map[i], end: map[i + needle.length] });
59
+ i = norm.indexOf(needle, i + needle.length);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Build a normalized view of `s` plus `map`, where `map[k]` is the original
65
+ * offset of the k-th normalized char and `map[norm.length]` is a sentinel
66
+ * (`s.length`). This lets a match found in normalized space splice back into
67
+ * the original bytes exactly.
68
+ */
69
+ function normalize(s, tier) {
70
+ const stripTrail = tier === "eol+trailws";
71
+ let norm = "";
72
+ const map = [];
73
+ // Whitespace whose trailing-vs-not status isn't known yet: flushed when real
74
+ // content follows on the line, dropped when a newline / EOF follows.
75
+ const pending = [];
76
+ const emit = (ch, off) => {
77
+ norm += ch;
78
+ map.push(off);
79
+ };
80
+ const flushPending = () => {
81
+ for (const p of pending)
82
+ emit(p.ch, p.off);
83
+ pending.length = 0;
84
+ };
85
+ let i = 0;
86
+ while (i < s.length) {
87
+ const c = s[i];
88
+ if (c === "\r" || c === "\n") {
89
+ pending.length = 0; // any pending whitespace was trailing → drop it
90
+ emit("\n", i);
91
+ i += c === "\r" && s[i + 1] === "\n" ? 2 : 1;
92
+ continue;
93
+ }
94
+ if (stripTrail && (c === " " || c === "\t")) {
95
+ pending.push({ ch: c, off: i });
96
+ i += 1;
97
+ continue;
98
+ }
99
+ flushPending();
100
+ emit(c, i);
101
+ i += 1;
102
+ }
103
+ // Trailing whitespace at end-of-string (in `pending`) is intentionally dropped.
104
+ map.push(s.length);
105
+ return { norm, map };
106
+ }
107
+ /** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
108
+ export function detectEol(content) {
109
+ const i = content.indexOf("\n");
110
+ return i > 0 && content[i - 1] === "\r" ? "\r\n" : "\n";
111
+ }
112
+ /** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
113
+ export function applyEol(text, eol) {
114
+ const lf = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
115
+ return eol === "\r\n" ? lf.replace(/\n/g, "\r\n") : lf;
116
+ }
117
+ /** Human phrase for the tier named in an ambiguity error. */
118
+ export function tierLabel(tier) {
119
+ switch (tier) {
120
+ case "exact":
121
+ return "";
122
+ case "eol":
123
+ return " after end-of-line normalization";
124
+ case "eol+trailws":
125
+ return " after end-of-line + trailing-whitespace normalization";
126
+ }
127
+ }
@@ -17,34 +17,20 @@ import type { HostResolver } from "./types.js";
17
17
  *
18
18
  * The check runs BEFORE any request is dispatched, and again on every redirect hop
19
19
  * (see fetch.ts). A block is a security refusal, distinct from a network failure.
20
+ *
21
+ * The IP range math, the pin shim, and the error types now live in the shared
22
+ * {@link ../net/ip-guard ip-guard} module (JC-A) — the ONE owner of "is this IP
23
+ * allowed"; this file keeps only the web-specific policy (scheme + the
24
+ * `allowPrivateHosts` escape hatch) and the web one-shot dispatcher.
20
25
  */
21
- /** Default resolver: node's `dns.lookup` returning ALL addresses. */
22
- export declare const defaultResolveHost: HostResolver;
23
- /** Thrown when a URL is refused pre-dispatch; carries a human reason. */
24
- export declare class BlockedHostError extends Error {
25
- }
26
- /** Thrown when the host could not be resolved (a network failure, not a block). */
27
- export declare class HostUnresolvedError extends Error {
28
- }
29
- /** True if an address (v4 or v6) is in a range `web_fetch` must never reach. */
30
- export declare function isBlockedAddress(addr: string): boolean;
31
- /**
32
- * A `dns.lookup`-compatible function that ignores the hostname and always hands
33
- * back one of the pre-validated `addresses`. This is what pins a connection to the
34
- * address the SSRF check already approved, defeating DNS rebinding: the socket can
35
- * only reach a validated IP, never a value re-resolved at connect time.
36
- */
37
- export declare function pinnedLookup(addresses: string[]): (_hostname: string, options: unknown, callback: (err: NodeJS.ErrnoException | null, address: string | {
38
- address: string;
39
- family: number;
40
- }[], family?: number) => void) => void;
26
+ export { BlockedHostError, HostUnresolvedError, isBlockedAddress, defaultResolveHost, pinnedLookup, } from "../net/ip-guard.js";
41
27
  /** An undici dispatcher whose connections are pinned to `addresses`. */
42
28
  export declare function createPinnedDispatcher(addresses: string[]): Dispatcher;
43
29
  /**
44
30
  * Assert that `url` may be fetched and return the validated addresses to pin the
45
31
  * connection to. Throws {@link BlockedHostError} for a bad scheme or a host
46
- * resolving into a blocked range, or {@link HostUnresolvedError} if the host cannot
47
- * be resolved.
32
+ * resolving into a blocked range, or `HostUnresolvedError` if the host cannot be
33
+ * resolved.
48
34
  *
49
35
  * The returned list is the exact set of addresses the caller must restrict the
50
36
  * connection to (via {@link createPinnedDispatcher}). An empty list means "do not
package/dist/web/ssrf.js CHANGED
@@ -1,5 +1,5 @@
1
- import { lookup } from "node:dns";
2
1
  import { Agent } from "undici";
2
+ import { assertAllPublic, BlockedHostError, pinnedLookup, } from "../net/ip-guard.js";
3
3
  /**
4
4
  * SSRF guard (C.20). A URL the MODEL chose must not be able to reach the user's
5
5
  * internal network, cloud metadata service, or loopback interface. Three layers:
@@ -17,170 +17,14 @@ import { Agent } from "undici";
17
17
  *
18
18
  * The check runs BEFORE any request is dispatched, and again on every redirect hop
19
19
  * (see fetch.ts). A block is a security refusal, distinct from a network failure.
20
+ *
21
+ * The IP range math, the pin shim, and the error types now live in the shared
22
+ * {@link ../net/ip-guard ip-guard} module (JC-A) — the ONE owner of "is this IP
23
+ * allowed"; this file keeps only the web-specific policy (scheme + the
24
+ * `allowPrivateHosts` escape hatch) and the web one-shot dispatcher.
20
25
  */
21
- /** Default resolver: node's `dns.lookup` returning ALL addresses. */
22
- export const defaultResolveHost = (host) => new Promise((resolve, reject) => {
23
- lookup(host, { all: true }, (err, addresses) => {
24
- if (err)
25
- reject(err);
26
- else
27
- resolve(addresses.map((a) => a.address));
28
- });
29
- });
30
- /** Thrown when a URL is refused pre-dispatch; carries a human reason. */
31
- export class BlockedHostError extends Error {
32
- }
33
- /** Thrown when the host could not be resolved (a network failure, not a block). */
34
- export class HostUnresolvedError extends Error {
35
- }
36
- /** Parse a dotted-quad IPv4 string into its four octets, or null. */
37
- function parseIpv4(ip) {
38
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
39
- if (!m)
40
- return null;
41
- const octets = m.slice(1, 5).map((s) => Number(s));
42
- if (octets.some((o) => o > 255))
43
- return null;
44
- return octets;
45
- }
46
- /** True if an IPv4 address falls in a private/loopback/link-local/reserved range. */
47
- function isBlockedIpv4(ip) {
48
- const octets = parseIpv4(ip);
49
- if (!octets)
50
- return false;
51
- const [a, b] = octets;
52
- if (a === 0)
53
- return true; // 0.0.0.0/8 "this network" / unspecified
54
- if (a === 10)
55
- return true; // 10.0.0.0/8 private
56
- if (a === 127)
57
- return true; // 127.0.0.0/8 loopback
58
- if (a === 169 && b === 254)
59
- return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
60
- if (a === 172 && b >= 16 && b <= 31)
61
- return true; // 172.16.0.0/12 private
62
- if (a === 192 && b === 168)
63
- return true; // 192.168.0.0/16 private
64
- if (a === 100 && b >= 64 && b <= 127)
65
- return true; // 100.64.0.0/10 CGNAT
66
- if (a === 198 && (b === 18 || b === 19))
67
- return true; // 198.18.0.0/15 benchmarking
68
- if (a === 255 && b === 255)
69
- return true; // broadcast-ish
70
- return false;
71
- }
72
- /**
73
- * Expand an IPv6 literal into its 8 sixteen-bit groups, or null if unparseable.
74
- * Handles `::` compression and an embedded IPv4 tail (`::ffff:127.0.0.1`).
75
- */
76
- function parseIpv6(input) {
77
- let s = input;
78
- const tail = [];
79
- // Peel off a trailing dotted-quad (IPv4-mapped/-compatible forms).
80
- const v4 = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
81
- if (v4) {
82
- const o = parseIpv4(v4[1]);
83
- if (!o)
84
- return null;
85
- tail.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
86
- s = s.slice(0, v4.index); // leaves a trailing ':' before the compression split
87
- }
88
- const halves = s.split("::");
89
- if (halves.length > 2)
90
- return null; // more than one "::" is illegal
91
- const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
92
- const rest = halves[1] ? halves[1].split(":").filter(Boolean) : [];
93
- const toNums = (groups) => {
94
- const out = [];
95
- for (const g of groups) {
96
- if (!/^[0-9a-f]{1,4}$/.test(g))
97
- return null;
98
- out.push(parseInt(g, 16));
99
- }
100
- return out;
101
- };
102
- const headNums = toNums(head);
103
- const restNums = toNums(rest);
104
- if (!headNums || !restNums)
105
- return null;
106
- let groups;
107
- if (halves.length === 2) {
108
- const fill = 8 - (headNums.length + restNums.length + tail.length);
109
- if (fill < 0)
110
- return null;
111
- groups = [
112
- ...headNums,
113
- ...Array(fill).fill(0),
114
- ...restNums,
115
- ...tail,
116
- ];
117
- }
118
- else {
119
- groups = [...headNums, ...tail];
120
- }
121
- return groups.length === 8 ? groups : null;
122
- }
123
- /** True if an expanded IPv6 address is in a range `web_fetch` must never reach. */
124
- function isBlockedIpv6(g) {
125
- // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): check the v4 part.
126
- const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
127
- const firstSixZero = firstFiveZero && g[5] === 0;
128
- const embedded = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
129
- if (firstFiveZero && g[5] === 0xffff)
130
- return isBlockedIpv4(embedded); // ::ffff:x
131
- if (firstSixZero &&
132
- !(g[6] === 0 && g[7] === 0) &&
133
- !(g[6] === 0 && g[7] === 1))
134
- return isBlockedIpv4(embedded); // ::x.y.z.w (IPv4-compatible, deprecated)
135
- if (g.every((x) => x === 0))
136
- return true; // :: unspecified
137
- if (firstSixZero && g[6] === 0 && g[7] === 1)
138
- return true; // ::1 loopback
139
- if ((g[0] & 0xffc0) === 0xfe80)
140
- return true; // fe80::/10 link-local (fe80–febf)
141
- if ((g[0] & 0xfe00) === 0xfc00)
142
- return true; // fc00::/7 unique-local (fc00–fdff)
143
- if ((g[0] & 0xff00) === 0xff00)
144
- return true; // ff00::/8 multicast
145
- return false;
146
- }
147
- /** True if an address (v4 or v6) is in a range `web_fetch` must never reach. */
148
- export function isBlockedAddress(addr) {
149
- // Strip IPv6 brackets and a scope/zone id (e.g. fe80::1%eth0).
150
- const ip = addr
151
- .trim()
152
- .toLowerCase()
153
- .replace(/^\[|\]$/g, "")
154
- .split("%")[0];
155
- if (ip.includes(":")) {
156
- const groups = parseIpv6(ip);
157
- if (!groups)
158
- return true; // fail closed: an unparseable colon-address is refused
159
- return isBlockedIpv6(groups);
160
- }
161
- return isBlockedIpv4(ip);
162
- }
163
- /**
164
- * A `dns.lookup`-compatible function that ignores the hostname and always hands
165
- * back one of the pre-validated `addresses`. This is what pins a connection to the
166
- * address the SSRF check already approved, defeating DNS rebinding: the socket can
167
- * only reach a validated IP, never a value re-resolved at connect time.
168
- */
169
- export function pinnedLookup(addresses) {
170
- const resolved = addresses.map((address) => ({
171
- address,
172
- family: address.includes(":") ? 6 : 4,
173
- }));
174
- return (_hostname, options, callback) => {
175
- const all = typeof options === "object" && options !== null && "all" in options
176
- ? options.all
177
- : false;
178
- if (all)
179
- callback(null, resolved);
180
- else
181
- callback(null, resolved[0].address, resolved[0].family);
182
- };
183
- }
26
+ // Re-export the shared surface so existing web importers/tests are unchanged.
27
+ export { BlockedHostError, HostUnresolvedError, isBlockedAddress, defaultResolveHost, pinnedLookup, } from "../net/ip-guard.js";
184
28
  /** An undici dispatcher whose connections are pinned to `addresses`. */
185
29
  export function createPinnedDispatcher(addresses) {
186
30
  return new Agent({ connect: { lookup: pinnedLookup(addresses) } });
@@ -188,8 +32,8 @@ export function createPinnedDispatcher(addresses) {
188
32
  /**
189
33
  * Assert that `url` may be fetched and return the validated addresses to pin the
190
34
  * connection to. Throws {@link BlockedHostError} for a bad scheme or a host
191
- * resolving into a blocked range, or {@link HostUnresolvedError} if the host cannot
192
- * be resolved.
35
+ * resolving into a blocked range, or `HostUnresolvedError` if the host cannot be
36
+ * resolved.
193
37
  *
194
38
  * The returned list is the exact set of addresses the caller must restrict the
195
39
  * connection to (via {@link createPinnedDispatcher}). An empty list means "do not
@@ -203,21 +47,5 @@ export async function assertFetchable(url, resolveHost, allowPrivate) {
203
47
  }
204
48
  if (allowPrivate)
205
49
  return [];
206
- const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
207
- let addresses;
208
- try {
209
- addresses = await resolveHost(host);
210
- }
211
- catch (err) {
212
- throw new HostUnresolvedError(`could not resolve host "${host}": ${err.message}`);
213
- }
214
- if (addresses.length === 0) {
215
- throw new HostUnresolvedError(`host "${host}" resolved to no addresses`);
216
- }
217
- for (const addr of addresses) {
218
- if (isBlockedAddress(addr)) {
219
- throw new BlockedHostError(`host "${host}" resolves to ${addr}, a private/loopback/link-local address`);
220
- }
221
- }
222
- return addresses;
50
+ return assertAllPublic(url.hostname, resolveHost);
223
51
  }
@@ -1,3 +1,4 @@
1
+ import type { HostResolver } from "../net/ip-guard.js";
1
2
  import type { WebConfig } from "../config/index.js";
2
3
  /**
3
4
  * Web subtool seams + shapes (C.20). Everything the `web_search`/`web_fetch`
@@ -44,9 +45,10 @@ export interface SearchProvider {
44
45
  /**
45
46
  * Resolve a hostname to its IP addresses. Injected so the SSRF guard can be tested
46
47
  * deterministically (a hostname that "resolves" to an internal IP) without real
47
- * DNS. Defaults to node's `dns.lookup` with `all: true`.
48
+ * DNS. Owned by the shared {@link ../net/ip-guard ip-guard} module (JC-A) and
49
+ * re-exported here for web importers.
48
50
  */
49
- export type HostResolver = (host: string) => Promise<string[]>;
51
+ export type { HostResolver } from "../net/ip-guard.js";
50
52
  /**
51
53
  * Injectable dependencies for the web tools. Defaults wire the real `fetch` and
52
54
  * DNS; tests pass spies/fakes. No global is ever patched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {