@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,4 +1,5 @@
1
1
  import { UNICODE_GLYPHS } from "../theme/index.js";
2
+ import { fit, visibleWidth } from "./layout.js";
2
3
  /**
3
4
  * The U.4 state→text mapping: pure data → string, like plan/render.ts and
4
5
  * diff.ts, so both renderers (and tests) share one composition with no
@@ -90,3 +91,40 @@ export function composeStatusLine(progress, phase, elapsedMs, glyph = UNICODE_GL
90
91
  }
91
92
  return line;
92
93
  }
94
+ /**
95
+ * The width-aware live line (U.12): {@link composeStatusLine} with honest
96
+ * degradation tiers so the status line never soft-wraps and never loses its
97
+ * essential meaning — *what is happening*. Priority is phase (the action) >
98
+ * the `[i/n] title` progress prefix (context) > the elapsed clock (decor), so
99
+ * as width shrinks we shed decor first and identity last:
100
+ *
101
+ * - wide: `[2/5] title · read_file src/x.ts… (12s)`
102
+ * - medium: drop the elapsed clock
103
+ * - narrow: drop the `[i/n] title` prefix, keep the phase
104
+ * - very-narrow: {@link fit} the phase text with an honest ellipsis
105
+ */
106
+ export function fitStatusLine(progress, phase, elapsedMs, glyph, width) {
107
+ const sep = ` ${glyph.sep} `;
108
+ const phaseText = phase ? describePhase(phase, glyph) : "";
109
+ const prefix = progress
110
+ ? `[${progress.step}/${progress.of}] ${progress.title}`
111
+ : "";
112
+ const elapsed = elapsedMs !== undefined && elapsedMs >= ELAPSED_AFTER_MS
113
+ ? ` (${formatElapsed(elapsedMs)})`
114
+ : "";
115
+ const join = (parts) => parts.filter((p) => p !== "").join(sep);
116
+ // The identity that must survive to the last: the phase, or the progress
117
+ // prefix when there is no phase (between-turns step context).
118
+ const essential = phaseText !== "" ? phaseText : prefix;
119
+ const candidates = [
120
+ join([prefix, phaseText]) + elapsed, // full
121
+ join([prefix, phaseText]), // drop elapsed
122
+ phaseText + elapsed, // drop progress prefix
123
+ essential, // essential only
124
+ ];
125
+ for (const c of candidates) {
126
+ if (c !== "" && visibleWidth(c) <= width)
127
+ return c;
128
+ }
129
+ return fit(essential, width, glyph.ellipsis);
130
+ }
@@ -25,6 +25,15 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
25
25
  * A committed write still only *hides* the drawn line (registers survive);
26
26
  * the next state transition redraws. Nothing redraws per streamed delta, so
27
27
  * the U.2 first-chunk-immediate guarantee is untouched.
28
+ *
29
+ * U.10 puts every live animation on ONE frame clock (see motion.ts) instead of
30
+ * a private interval: the spinner glyph and the phase-transition emphasis draw
31
+ * off `clock.frame`, subscribed only while the line is visible and torn down on
32
+ * hide/close (no leaked timer). Reduced motion disables the clock — the line is
33
+ * drawn once in its static end-state. The result reveal is a one-time entrance
34
+ * emphasis on a committed tool note (never a redraw of committed bytes), so it
35
+ * needs no clock. Motion is a cosmetic overlay on the live region only; it never
36
+ * gates a committed write, so the latency guarantee holds with motion active.
28
37
  */
29
38
  export declare class TtyRenderer implements StreamRenderer {
30
39
  readonly caps: RenderCapabilities;
@@ -45,10 +54,22 @@ export declare class TtyRenderer implements StreamRenderer {
45
54
  private displaced;
46
55
  /** Whether the live line is currently drawn on screen. */
47
56
  private lineVisible;
48
- private timer;
49
- private frame;
57
+ /** The one frame clock driving live animation (U.10); disabled under reduced motion. */
58
+ private readonly clock;
59
+ /** Detach from the clock; null when the live line is hidden (no ticks wanted). */
60
+ private unsubscribeFrame;
61
+ /** Clock frame the current phase *identity* began on — drives the transition emphasis (U.10). */
62
+ private phaseStartFrame;
50
63
  private closed;
64
+ /** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
65
+ private unsubscribeResize;
51
66
  constructor(caps: RenderCapabilities, out: RenderStream);
67
+ /**
68
+ * Redraw the live line at the current `caps.width` after a resize. A no-op
69
+ * when nothing is drawn (so a resize between turns writes zero bytes) — the
70
+ * next state transition will draw at the new width anyway.
71
+ */
72
+ private reflowLive;
52
73
  private newPrinter;
53
74
  /** Append committed content, erasing the status line first if one is live. */
54
75
  private commit;
@@ -59,7 +80,8 @@ export declare class TtyRenderer implements StreamRenderer {
59
80
  * zero-cost: no redraw-under per delta.)
60
81
  */
61
82
  private hideLine;
62
- private stopTimer;
83
+ /** Detach from the frame clock — the timer stops once this is the last subscriber. */
84
+ private stopFrames;
63
85
  /**
64
86
  * The current live-line text, composed from the registers. `null` means the
65
87
  * line must be hidden: nothing to say, or an interactive prompt owns the
@@ -2,10 +2,11 @@ import { resolveTheme } from "../theme/index.js";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { createStreamHighlighter, } from "./highlight.js";
5
- import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
5
+ import { fit } from "./layout.js";
6
+ import { createFrameClock, inTransition, spinnerGlyph, } from "./motion.js";
7
+ import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "./state.js";
6
8
  /** Erase the current line and return the cursor to column 0. */
7
9
  const CLEAR_LINE = "\r\x1b[2K";
8
- const SPINNER_INTERVAL_MS = 100;
9
10
  /**
10
11
  * The interactive renderer: committed content is append-only; the one transient
11
12
  * thing on screen is a single managed status line, redrawn in place.
@@ -30,6 +31,15 @@ const SPINNER_INTERVAL_MS = 100;
30
31
  * A committed write still only *hides* the drawn line (registers survive);
31
32
  * the next state transition redraws. Nothing redraws per streamed delta, so
32
33
  * the U.2 first-chunk-immediate guarantee is untouched.
34
+ *
35
+ * U.10 puts every live animation on ONE frame clock (see motion.ts) instead of
36
+ * a private interval: the spinner glyph and the phase-transition emphasis draw
37
+ * off `clock.frame`, subscribed only while the line is visible and torn down on
38
+ * hide/close (no leaked timer). Reduced motion disables the clock — the line is
39
+ * drawn once in its static end-state. The result reveal is a one-time entrance
40
+ * emphasis on a committed tool note (never a redraw of committed bytes), so it
41
+ * needs no clock. Motion is a cosmetic overlay on the live region only; it never
42
+ * gates a committed write, so the latency guarantee holds with motion active.
33
43
  */
34
44
  export class TtyRenderer {
35
45
  caps;
@@ -50,15 +60,43 @@ export class TtyRenderer {
50
60
  displaced = null;
51
61
  /** Whether the live line is currently drawn on screen. */
52
62
  lineVisible = false;
53
- timer = null;
54
- frame = 0;
63
+ /** The one frame clock driving live animation (U.10); disabled under reduced motion. */
64
+ clock;
65
+ /** Detach from the clock; null when the live line is hidden (no ticks wanted). */
66
+ unsubscribeFrame = null;
67
+ /** Clock frame the current phase *identity* began on — drives the transition emphasis (U.10). */
68
+ phaseStartFrame = 0;
55
69
  closed = false;
70
+ /** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
71
+ unsubscribeResize = null;
56
72
  constructor(caps, out) {
57
73
  this.caps = caps;
58
74
  this.out = out;
59
75
  this.theme = resolveTheme(caps);
60
76
  this.highlighter = createStreamHighlighter(this.theme);
61
77
  this.print = this.newPrinter();
78
+ // One clock for all live motion (U.10). Gated on `caps.spinner`
79
+ // (cursor && !reducedMotion), so reduced motion yields an inert clock that
80
+ // never schedules or ticks — the line is drawn once, statically.
81
+ this.clock = createFrameClock(caps.spinner);
82
+ // Resize reactivity (U.12): only the live line reflows at the new width —
83
+ // committed rows above are immutable and never rewritten.
84
+ this.unsubscribeResize = caps.onResize?.(() => this.reflowLive()) ?? null;
85
+ }
86
+ /**
87
+ * Redraw the live line at the current `caps.width` after a resize. A no-op
88
+ * when nothing is drawn (so a resize between turns writes zero bytes) — the
89
+ * next state transition will draw at the new width anyway.
90
+ */
91
+ reflowLive() {
92
+ if (this.closed || !this.lineVisible)
93
+ return;
94
+ const line = this.currentLine();
95
+ if (line === null) {
96
+ this.hideLine();
97
+ return;
98
+ }
99
+ this.drawLine(line);
62
100
  }
63
101
  newPrinter() {
64
102
  return createStreamPrinter((text) => {
@@ -86,13 +124,14 @@ export class TtyRenderer {
86
124
  if (!this.lineVisible)
87
125
  return;
88
126
  this.lineVisible = false;
89
- this.stopTimer();
127
+ this.stopFrames();
90
128
  this.out.write(CLEAR_LINE);
91
129
  }
92
- stopTimer() {
93
- if (this.timer !== null) {
94
- clearInterval(this.timer);
95
- this.timer = null;
130
+ /** Detach from the frame clock — the timer stops once this is the last subscriber. */
131
+ stopFrames() {
132
+ if (this.unsubscribeFrame !== null) {
133
+ this.unsubscribeFrame();
134
+ this.unsubscribeFrame = null;
96
135
  }
97
136
  }
98
137
  /**
@@ -112,7 +151,10 @@ export class TtyRenderer {
112
151
  const elapsed = this.phase !== null && this.caps.spinner
113
152
  ? Date.now() - this.phaseStartedAt
114
153
  : undefined;
115
- return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
154
+ // Reserve the glyph + space (2 cols): the composed line is degraded and
155
+ // fitted to the remainder so it can never soft-wrap (U.12).
156
+ const room = Math.max(1, this.caps.width - 2);
157
+ return fitStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph, room);
116
158
  }
117
159
  /** Redraw the live line from current state, or hide it when there is none. */
118
160
  refresh() {
@@ -122,29 +164,36 @@ export class TtyRenderer {
122
164
  return;
123
165
  }
124
166
  this.drawLine(line);
125
- if (this.caps.spinner && this.timer === null) {
126
- this.timer = setInterval(() => {
127
- this.frame++;
167
+ // Subscribe to the one clock while the line is live; the clock owns the
168
+ // single (unref'd) timer and only runs while something is subscribed. An
169
+ // inert clock (reduced motion) subscribes to nothing — the static draw
170
+ // above is the whole story, no frames follow.
171
+ if (this.clock.enabled && this.unsubscribeFrame === null) {
172
+ this.unsubscribeFrame = this.clock.subscribe(() => {
128
173
  const current = this.currentLine();
129
174
  if (current !== null)
130
175
  this.drawLine(current);
131
- }, SPINNER_INTERVAL_MS);
132
- // Never hold the process open for a spinner.
133
- this.timer.unref?.();
176
+ });
134
177
  }
135
178
  }
136
179
  drawLine(text) {
137
180
  this.lineVisible = true;
138
- const frames = this.theme.glyph.spinnerFrames;
139
- const glyph = this.caps.spinner
140
- ? frames[this.frame % frames.length]
141
- : this.theme.glyph.spinnerStatic;
142
- // Reserve glyph + space; truncate so the live line can never soft-wrap.
181
+ const animated = this.caps.spinner;
182
+ const glyph = spinnerGlyph(this.clock.frame, this.theme.glyph, animated);
183
+ // Reserve glyph + space; ANSI-aware fit so the live line can never
184
+ // soft-wrap (currentLine already fits — this is defense in depth) (U.12).
143
185
  const room = Math.max(1, this.caps.width - 2);
144
- const line = text.length > room
145
- ? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
146
- : text;
147
- this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
186
+ const line = fit(text, room, this.theme.glyph.ellipsis);
187
+ // Phase-transition emphasis (U.10): a newly-begun activity brightens for a
188
+ // few frames, then settles to muted. Purely a color role, so NO_COLOR and
189
+ // the static (reduced-motion) draw both collapse to the settled end-state —
190
+ // and it only ever plays on a live phase whose identity just changed.
191
+ const emphasized = animated &&
192
+ this.rawStatus === null &&
193
+ this.phase !== null &&
194
+ inTransition(this.clock.frame, this.phaseStartFrame);
195
+ const body = emphasized ? this.theme.accent(line) : this.theme.muted(line);
196
+ this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${body}`);
148
197
  }
149
198
  beginTurn() {
150
199
  this.highlighter = createStreamHighlighter(this.theme);
@@ -168,16 +217,16 @@ export class TtyRenderer {
168
217
  note(text) {
169
218
  if (this.closed)
170
219
  return;
171
- const room = Math.max(1, this.caps.width);
172
- const line = text.length > room
173
- ? text.slice(0, room - 1) + this.theme.glyph.ellipsis
174
- : text;
220
+ // ANSI-aware fit (U.12): a note may carry color (subagent trail marks), so
221
+ // width must be measured on visible chars, not bytes, or it truncates early
222
+ // and can sever an escape sequence.
223
+ const line = fit(text, Math.max(1, this.caps.width), this.theme.glyph.ellipsis);
175
224
  this.commit(this.theme.muted(line) + "\n");
176
225
  }
177
226
  preview(preview) {
178
227
  if (this.closed)
179
228
  return;
180
- const block = renderActionPreview(preview, this.theme);
229
+ const block = renderActionPreview(preview, this.theme, this.caps.width);
181
230
  if (block)
182
231
  this.commit(block + "\n");
183
232
  }
@@ -207,8 +256,13 @@ export class TtyRenderer {
207
256
  }
208
257
  const before = phaseIdentity(this.phase);
209
258
  this.phase = phase;
210
- if (phaseIdentity(phase) !== before)
259
+ if (phaseIdentity(phase) !== before) {
211
260
  this.phaseStartedAt = Date.now();
261
+ // A new activity: restart the transition emphasis from the current frame
262
+ // (U.10). Only a genuine identity change resets it — a thinking phase that
263
+ // merely gains token counts keeps drawing settled, no re-flourish.
264
+ this.phaseStartFrame = this.clock.frame;
265
+ }
212
266
  if (phase === null) {
213
267
  // A cleared phase means "nothing is happening" — hide rather than
214
268
  // redraw a bare progress prefix between turns.
@@ -240,7 +294,13 @@ export class TtyRenderer {
240
294
  const elapsed = started === null ? 0 : Date.now() - started.at;
241
295
  const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
242
296
  const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
243
- this.note(`${mark} ${event.label}${suffix}`);
297
+ // Result reveal (U.10): a freshly-landed result gets a one-time entrance
298
+ // emphasis on its mark when motion is active — written once into committed
299
+ // history and never redrawn (immutable). It gates on the same motion axis,
300
+ // so reduced motion shows the plain mark; under NO_COLOR `strong` is the
301
+ // identity, so the committed bytes are byte-for-byte unchanged either way.
302
+ const shownMark = this.caps.spinner ? this.theme.strong(mark) : mark;
303
+ this.note(`${shownMark} ${event.label}${suffix}`);
244
304
  }
245
305
  promptResolved() {
246
306
  if (this.closed)
@@ -274,6 +334,8 @@ export class TtyRenderer {
274
334
  if (this.closed)
275
335
  return;
276
336
  this.endTurn();
337
+ this.unsubscribeResize?.();
338
+ this.unsubscribeResize = null;
277
339
  this.closed = true;
278
340
  }
279
341
  }
@@ -39,8 +39,22 @@ export interface RenderCapabilities {
39
39
  /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
40
40
  * independent of `color`. Drives the theme's glyph table, not its stylers. */
41
41
  unicode: boolean;
42
- /** Terminal columns; 80 when unknown (non-TTY). */
42
+ /**
43
+ * Terminal columns (U.12) — the ONE width source every surface reads; 80 when
44
+ * unknown (non-TTY / no `columns`). Honors `COLUMNS` when set. Mutated in
45
+ * place on resize (see {@link onResize}), so a surface reading it after a
46
+ * SIGWINCH sees the new width without re-probing anything.
47
+ */
43
48
  width: number;
49
+ /**
50
+ * Subscribe to width changes (U.12): the SIGWINCH push signal for the live
51
+ * region / transient frames to reflow at the new width. The listener fires
52
+ * with the new width AFTER {@link width} has been updated; the returned
53
+ * function unsubscribes. Absent when the stream cannot resize (non-TTY / no
54
+ * `columns`) — those surfaces just read the static {@link width}. Committed
55
+ * output is never re-rendered from here; only live surfaces subscribe.
56
+ */
57
+ onResize?(listener: (width: number) => void): () => void;
44
58
  }
45
59
  /** Accumulated token usage the loop already tracks (U.4) — never fabricated. */
46
60
  export interface TokenUsage {
@@ -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.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {