@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/render/state.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
private
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
54
|
-
|
|
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.
|
|
127
|
+
this.stopFrames();
|
|
90
128
|
this.out.write(CLEAR_LINE);
|
|
91
129
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
}
|
|
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
|
|
139
|
-
const glyph = this.
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/render/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/dist/web/ssrf.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
47
|
-
*
|
|
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
|
-
|
|
22
|
-
export
|
|
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
|
|
192
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/dist/web/types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|