@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.13

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 (57) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/ask-modal.js +2 -2
  48. package/dist/tui/conversation-pane.js +1 -1
  49. package/dist/tui/input-box.js +1 -1
  50. package/dist/tui/markdown-render.js +4 -4
  51. package/dist/tui/repl-render.js +105 -15
  52. package/dist/tui/repl-splash.js +2 -2
  53. package/dist/tui/repl.js +10 -4
  54. package/dist/tui/splash.js +1 -1
  55. package/dist/tui/status-bar.js +94 -16
  56. package/dist/tui/update-banner.js +20 -2
  57. package/package.json +5 -4
@@ -0,0 +1,361 @@
1
+ /**
2
+ * multi_edit tool — β7 (2026-05-26).
3
+ *
4
+ * Dispatches an ordered batch of file edits as a single transaction. Each
5
+ * edit is one Layer A (oldString -> newString) operation against one
6
+ * workspace file. Either every edit lands, or none do — failures roll
7
+ * the workspace back to the pre-dispatch state using the same journal +
8
+ * snapshot machinery the β1b Pl8 transactional layer uses for the
9
+ * marker-driven dispatcher.
10
+ *
11
+ * Why multi_edit when `edit` already exists:
12
+ *
13
+ * The single-shot `edit` tool is the right primitive for one mutation;
14
+ * the model uses it dozens of times in a typical session. A coordinated
15
+ * refactor (rename across 8 files, add an import to 12 modules, peel a
16
+ * helper into 5 callers) is currently 8/12/5 separate `edit` calls.
17
+ * Each call is its own audit + permission check + atomic write, which
18
+ * is the right shape for the audit story but means the model can leave
19
+ * the workspace half-mutated when one of the calls fails partway. The
20
+ * model also pays the round-trip latency once per call.
21
+ *
22
+ * `multi_edit` collapses the 8/12/5 calls into one tool dispatch with
23
+ * transactional semantics: snapshot every target file, attempt every
24
+ * edit against an in-memory buffer, then commit the writes only after
25
+ * all in-memory edits succeed. A failure rolls back via journal +
26
+ * in-memory snapshot — same code path as the dispatcher.
27
+ *
28
+ * Security: every target file routes through the same `applySecurityGate`
29
+ * chokepoint Layer A/B/C inherit. A path that escapes the workspace,
30
+ * points at a protected basename (`.env`, `*.pem`, ...), or symlinks
31
+ * outside the tree is refused BEFORE any read.
32
+ *
33
+ * Concurrency: marked `concurrencySafe: false` in the tool registry. The
34
+ * model MUST NOT issue another `multi_edit` (or any write tool) in
35
+ * parallel with one in flight; the journal serialises one dispatch per
36
+ * session.
37
+ *
38
+ * Output cap: a 50-edit batch is the soft ceiling. Beyond that the tool
39
+ * refuses with `too_many_edits` — the operator can split the refactor.
40
+ * Empirically a coordinated refactor that needs 50+ atomic edits should
41
+ * be a per-file Layer C rewrite instead.
42
+ *
43
+ * Brand voice: ASCII only, no emoji, no banned words.
44
+ */
45
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
46
+ import { applySecurityGate } from '../core/edits/security-gate.js';
47
+ import { appendEntry, snapshotForDispatch, } from '../core/edits/journal.js';
48
+ import { rollbackDispatch } from '../core/edits/dispatch.js';
49
+ import { gateOnCancellation, OperatorAbortedError } from './file-tools.js';
50
+ import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
51
+ /** Soft cap on per-dispatch edit count. See module docstring. */
52
+ const MULTI_EDIT_MAX = 50;
53
+ /**
54
+ * Apply a batch of file edits transactionally. Returns a structured
55
+ * result; never throws on operator-attributable failure (security,
56
+ * missing file, no_match) — only on infrastructure error (filesystem
57
+ * permission denied mid-write after the snapshot, etc.).
58
+ */
59
+ export function multiEdit(ctx, edits, opts = {}) {
60
+ const toolCallId = recordToolCall(ctx.session, 'multi_edit', `${edits.length} edits across ${new Set(edits.map((e) => e.file)).size} files`);
61
+ try {
62
+ gateOnCancellation(ctx, 'multi_edit');
63
+ }
64
+ catch (error) {
65
+ if (error instanceof OperatorAbortedError) {
66
+ recordToolResult(ctx.session, toolCallId, 'cancelled', error.message);
67
+ throw error;
68
+ }
69
+ throw error;
70
+ }
71
+ if (edits.length === 0) {
72
+ const result = {
73
+ ok: false,
74
+ filesChanged: [],
75
+ editsApplied: 0,
76
+ reason: 'empty_batch',
77
+ detail: 'multi_edit received zero edits',
78
+ perEdit: [],
79
+ };
80
+ recordToolResult(ctx.session, toolCallId, 'error', 'empty_batch');
81
+ return result;
82
+ }
83
+ if (edits.length > MULTI_EDIT_MAX) {
84
+ const result = {
85
+ ok: false,
86
+ filesChanged: [],
87
+ editsApplied: 0,
88
+ reason: 'too_many_edits',
89
+ detail: `multi_edit batch of ${edits.length} exceeds cap ${MULTI_EDIT_MAX}; split the refactor`,
90
+ perEdit: [],
91
+ };
92
+ recordToolResult(ctx.session, toolCallId, 'error', 'too_many_edits');
93
+ return result;
94
+ }
95
+ // SECURITY GATE pass over every distinct file BEFORE any read.
96
+ // A single rejected file aborts the whole batch — the transactional
97
+ // contract requires we never partial-mutate.
98
+ const uniqueFiles = Array.from(new Set(edits.map((e) => e.file)));
99
+ const resolvedByFile = new Map();
100
+ for (const f of uniqueFiles) {
101
+ const gate = applySecurityGate(f, { cwd: ctx.root, toolName: 'layer-c' });
102
+ if (!gate.ok) {
103
+ const result = {
104
+ ok: false,
105
+ filesChanged: [],
106
+ editsApplied: 0,
107
+ reason: gate.reason,
108
+ detail: `${f}: ${gate.detail}`,
109
+ perEdit: edits.map((e, i) => ({
110
+ index: i,
111
+ file: e.file,
112
+ ok: false,
113
+ reason: gate.reason,
114
+ detail: e.file === f ? gate.detail : 'batch aborted by sibling security failure',
115
+ })),
116
+ };
117
+ recordToolResult(ctx.session, toolCallId, 'error', `${gate.reason}: ${f}`);
118
+ return result;
119
+ }
120
+ resolvedByFile.set(f, gate.absPath);
121
+ }
122
+ // Snapshot existing files BEFORE any in-memory edit so a partial-write
123
+ // rollback is deterministic. The snapshot also captures sha256 of each
124
+ // pre-existing file so post-failure restore can verify the in-memory
125
+ // buffer still matches.
126
+ const snapshot = snapshotForDispatch(ctx.root, uniqueFiles);
127
+ const preContent = new Map();
128
+ for (const entry of snapshot) {
129
+ if (!entry.existed)
130
+ continue;
131
+ const abs = resolvedByFile.get(entry.path);
132
+ if (!abs)
133
+ continue;
134
+ try {
135
+ preContent.set(entry.path, readFileSync(abs));
136
+ }
137
+ catch {
138
+ // Best-effort. A read failure here will surface again when the
139
+ // per-edit phase tries to read the same file — let that path
140
+ // produce the operator-facing error.
141
+ }
142
+ }
143
+ // In-memory edit phase. For each edit we work on the latest version
144
+ // of the file (so two edits against the same file stack). Failure
145
+ // here is the common case — `no_match`, `ambiguous_match`, missing
146
+ // file — and aborts the whole batch.
147
+ const bodyByFile = new Map();
148
+ const perEdit = [];
149
+ for (let i = 0; i < edits.length; i += 1) {
150
+ const edit = edits[i];
151
+ const abs = resolvedByFile.get(edit.file);
152
+ if (!abs) {
153
+ // Should be unreachable — every distinct file went through the
154
+ // gate above. Belt + braces.
155
+ perEdit.push({ index: i, file: edit.file, ok: false, reason: 'write_error', detail: 'no resolved path' });
156
+ const result = {
157
+ ok: false,
158
+ filesChanged: [],
159
+ editsApplied: 0,
160
+ reason: 'write_error',
161
+ detail: `${edit.file}: no resolved path`,
162
+ perEdit,
163
+ };
164
+ recordToolResult(ctx.session, toolCallId, 'error', 'write_error');
165
+ return result;
166
+ }
167
+ let body = bodyByFile.get(edit.file);
168
+ if (body === undefined) {
169
+ if (!existsSync(abs)) {
170
+ const detail = `file does not exist: ${edit.file}`;
171
+ perEdit.push({ index: i, file: edit.file, ok: false, reason: 'file_missing', detail });
172
+ const result = {
173
+ ok: false,
174
+ filesChanged: [],
175
+ editsApplied: 0,
176
+ reason: 'file_missing',
177
+ detail,
178
+ perEdit,
179
+ };
180
+ recordToolResult(ctx.session, toolCallId, 'error', 'file_missing');
181
+ return result;
182
+ }
183
+ try {
184
+ body = readFileSync(abs, 'utf8');
185
+ }
186
+ catch (error) {
187
+ const detail = error instanceof Error ? error.message : String(error);
188
+ perEdit.push({ index: i, file: edit.file, ok: false, reason: 'write_error', detail });
189
+ const result = {
190
+ ok: false,
191
+ filesChanged: [],
192
+ editsApplied: 0,
193
+ reason: 'write_error',
194
+ detail: `${edit.file}: ${detail}`,
195
+ perEdit,
196
+ };
197
+ recordToolResult(ctx.session, toolCallId, 'error', 'write_error');
198
+ return result;
199
+ }
200
+ }
201
+ if (edit.oldString === edit.newString) {
202
+ perEdit.push({
203
+ index: i,
204
+ file: edit.file,
205
+ ok: false,
206
+ reason: 'identical_replacement',
207
+ detail: 'oldString and newString are identical',
208
+ });
209
+ const result = {
210
+ ok: false,
211
+ filesChanged: [],
212
+ editsApplied: 0,
213
+ reason: 'identical_replacement',
214
+ detail: `edit ${i} (${edit.file}): oldString and newString are identical`,
215
+ perEdit,
216
+ };
217
+ recordToolResult(ctx.session, toolCallId, 'error', 'identical_replacement');
218
+ return result;
219
+ }
220
+ const matches = countOccurrences(body, edit.oldString);
221
+ if (matches === 0) {
222
+ const detail = `edit ${i} (${edit.file}): oldString not found`;
223
+ perEdit.push({ index: i, file: edit.file, ok: false, reason: 'no_match', detail });
224
+ const result = {
225
+ ok: false,
226
+ filesChanged: [],
227
+ editsApplied: 0,
228
+ reason: 'no_match',
229
+ detail,
230
+ perEdit,
231
+ };
232
+ recordToolResult(ctx.session, toolCallId, 'error', 'no_match');
233
+ return result;
234
+ }
235
+ if (matches > 1) {
236
+ const detail = `edit ${i} (${edit.file}): oldString matches ${matches} times — expand context to make it unique`;
237
+ perEdit.push({ index: i, file: edit.file, ok: false, reason: 'ambiguous_match', detail });
238
+ const result = {
239
+ ok: false,
240
+ filesChanged: [],
241
+ editsApplied: 0,
242
+ reason: 'ambiguous_match',
243
+ detail,
244
+ perEdit,
245
+ };
246
+ recordToolResult(ctx.session, toolCallId, 'error', 'ambiguous_match');
247
+ return result;
248
+ }
249
+ body = body.replace(edit.oldString, edit.newString);
250
+ bodyByFile.set(edit.file, body);
251
+ perEdit.push({ index: i, file: edit.file, ok: true });
252
+ }
253
+ if (opts.dryRun) {
254
+ const result = {
255
+ ok: true,
256
+ filesChanged: Array.from(bodyByFile.keys()),
257
+ editsApplied: edits.length,
258
+ perEdit,
259
+ };
260
+ recordToolResult(ctx.session, toolCallId, 'success', `dry-run ${edits.length} edits ok`);
261
+ return result;
262
+ }
263
+ // Persist the snapshot to the journal BEFORE the first write. A crash
264
+ // mid-write then has a recoverable trail in `.pugi/sessions/<id>/journal.jsonl`.
265
+ // Best-effort; a journal write failure does not block the edits (the
266
+ // in-memory rollback path still covers same-process failures).
267
+ if (ctx.session.enabled) {
268
+ appendEntry(ctx.root, ctx.session.id, {
269
+ ts: Date.now(),
270
+ taskId: `multi_edit-${toolCallId}`,
271
+ files: snapshot,
272
+ });
273
+ }
274
+ // Commit phase. Atomic writes one file at a time. A failure rolls
275
+ // back via the same dispatcher rollback used by the marker layer.
276
+ const written = [];
277
+ for (const [file, body] of bodyByFile) {
278
+ const abs = resolvedByFile.get(file);
279
+ try {
280
+ atomicWrite(abs, body);
281
+ written.push(file);
282
+ }
283
+ catch (error) {
284
+ const detail = error instanceof Error ? error.message : String(error);
285
+ // Roll back every file we already touched plus restore the
286
+ // not-yet-touched ones that existed before (defensive — the
287
+ // rollback function is idempotent on untouched paths).
288
+ const rollback = rollbackDispatch(ctx.root, snapshot, preContent);
289
+ if (!rollback.ok) {
290
+ const result = {
291
+ ok: false,
292
+ filesChanged: [],
293
+ editsApplied: 0,
294
+ reason: 'rollback_failed',
295
+ detail: `${file}: ${detail}; rollback also failed: ${rollback.detail}`,
296
+ perEdit,
297
+ };
298
+ recordToolResult(ctx.session, toolCallId, 'error', 'rollback_failed');
299
+ return result;
300
+ }
301
+ const result = {
302
+ ok: false,
303
+ filesChanged: [],
304
+ editsApplied: 0,
305
+ reason: 'write_error',
306
+ detail: `${file}: ${detail}`,
307
+ perEdit,
308
+ };
309
+ recordToolResult(ctx.session, toolCallId, 'error', `write_error: ${detail}`);
310
+ return result;
311
+ }
312
+ }
313
+ for (const file of written) {
314
+ recordFileMutation(ctx.session, {
315
+ toolCallId,
316
+ path: file,
317
+ operation: 'update',
318
+ });
319
+ }
320
+ recordToolResult(ctx.session, toolCallId, 'success', `applied ${edits.length} edits across ${written.length} files`);
321
+ return {
322
+ ok: true,
323
+ filesChanged: written,
324
+ editsApplied: edits.length,
325
+ perEdit,
326
+ };
327
+ }
328
+ function countOccurrences(haystack, needle) {
329
+ if (needle.length === 0)
330
+ return 0;
331
+ let count = 0;
332
+ let from = 0;
333
+ while (true) {
334
+ const idx = haystack.indexOf(needle, from);
335
+ if (idx === -1)
336
+ return count;
337
+ count += 1;
338
+ from = idx + needle.length;
339
+ }
340
+ }
341
+ /** Atomic write helper — mirrors Layer A / Layer D. */
342
+ function atomicWrite(absPath, contents) {
343
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
344
+ const tmp = `${absPath}.pugi-tmp-${suffix}`;
345
+ try {
346
+ writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
347
+ renameSync(tmp, absPath);
348
+ }
349
+ catch (error) {
350
+ try {
351
+ unlinkSync(tmp);
352
+ }
353
+ catch {
354
+ // tmp file may not exist if writeFileSync itself failed.
355
+ }
356
+ throw error;
357
+ }
358
+ }
359
+ /** Test-only surface. */
360
+ export const __test__ = { MULTI_EDIT_MAX };
361
+ //# sourceMappingURL=multi-edit.js.map
@@ -14,6 +14,11 @@ const registry = [
14
14
  { name: 'lsp_diagnostics', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
15
15
  { name: 'lsp_hover', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
16
16
  { name: 'lsp_references', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
17
+ // β7 L5+T11: multi_edit dispatches an ordered batch of Layer A edits
18
+ // as a single transaction. Risk = medium (same chokepoints as `edit`).
19
+ // concurrencySafe = false because the journal serialises one dispatch
20
+ // per session.
21
+ { name: 'multi_edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
17
22
  { name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
18
23
  { name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
19
24
  { name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
@@ -34,7 +34,7 @@
34
34
  * Brand voice: brief / dispatch / ship / sentinel only. The
35
35
  * brandbook §08 forbidden-word list applies — see CLAUDE.md.
36
36
  */
37
- import { request } from 'undici';
37
+ import { request, Agent } from 'undici';
38
38
  import { Readability } from '@mozilla/readability';
39
39
  import { parseHTML } from 'linkedom';
40
40
  import TurndownService from 'turndown';
@@ -45,6 +45,72 @@ let activeLookup = async (hostname) => await dnsLookup(hostname, { all: true, ve
45
45
  export function _setLookupForTests(fn) {
46
46
  activeLookup = fn ?? (async (hostname) => await dnsLookup(hostname, { all: true, verbatim: true }));
47
47
  }
48
+ /**
49
+ * β1b #62 — DNS rebinding guard via pinned-address Dispatcher.
50
+ *
51
+ * Without this, the SSRF guard's `dns.lookup` and undici's `request()`
52
+ * connect(2) each issue independent DNS queries. A hostile resolver
53
+ * can answer "8.8.8.8" the first time (passes the SSRF guard) and
54
+ * "127.0.0.1" the second time (kernel connects to local metadata).
55
+ *
56
+ * Fix: resolve once, validate, then pin the resolved address into a
57
+ * per-call `Agent` via `connect.lookup`. The connect() path no longer
58
+ * touches DNS — it uses the IP we already approved.
59
+ *
60
+ * Test seam: spec suite uses MockAgent as the global dispatcher; the
61
+ * MockAgent path does not exercise real connect(), so pinning is both
62
+ * pointless and would break the MockAgent stub. Specs flip
63
+ * `_disablePinnedDispatcherForTests(true)` in beforeEach to keep the
64
+ * MockAgent flow intact while production hits the pinned path.
65
+ */
66
+ let pinnedDispatcherDisabled = false;
67
+ export function _disablePinnedDispatcherForTests(disabled) {
68
+ pinnedDispatcherDisabled = disabled;
69
+ }
70
+ /**
71
+ * Build a per-call undici Agent that always returns the pre-resolved
72
+ * `address` from its connect.lookup hook. Returns `undefined` when the
73
+ * test flag disabled pinning — caller then falls back to the global
74
+ * dispatcher (MockAgent or production default).
75
+ */
76
+ async function buildPinnedDispatcher(hostname) {
77
+ if (pinnedDispatcherDisabled)
78
+ return undefined;
79
+ // Skip pinning when hostname is already a literal IP — there is no
80
+ // DNS step to race in that case.
81
+ if (isIPv4(hostname) || isIPv6(hostname))
82
+ return undefined;
83
+ let answers;
84
+ try {
85
+ answers = await activeLookup(hostname);
86
+ }
87
+ catch {
88
+ // Best-effort — fall through without pinning; the SSRF guard will
89
+ // emit the canonical DNS-lookup-failed error on the caller's path.
90
+ return undefined;
91
+ }
92
+ const pinned = answers[0];
93
+ if (!pinned)
94
+ return undefined;
95
+ // β1b r1: close the DNS rebinding window the original guard could
96
+ // not see. `validateHostnameForFetch` already ran one lookup; the
97
+ // call above is a SECOND lookup whose answer feeds the pin. A
98
+ // hostile resolver can return a public address to the guard and a
99
+ // private address here — re-validate the pinned literal before we
100
+ // hand it to the Agent. Throws so the caller surfaces a security
101
+ // refusal rather than silently dispatching to the wrong host.
102
+ const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
103
+ if (ipCheck !== null) {
104
+ throw new Error(`ssrf_pinned_address_blocked: ${ipCheck}`);
105
+ }
106
+ return new Agent({
107
+ connect: {
108
+ lookup: (_h, _opts, cb) => {
109
+ cb(null, pinned.address, pinned.family);
110
+ },
111
+ },
112
+ });
113
+ }
48
114
  const FETCH_TIMEOUT_MS = 10_000;
49
115
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MiB
50
116
  const MAX_REDIRECTS = 5;
@@ -231,6 +297,42 @@ function ipv4IsBlocked(ip) {
231
297
  }
232
298
  return false;
233
299
  }
300
+ /**
301
+ * Validate a single IP literal (v4 or v6) against the SSRF blocklist.
302
+ * Pure synchronous check — no DNS. Returns `null` on success (safe to
303
+ * connect), an error string when the address is blocked or not a
304
+ * recognized IP literal.
305
+ *
306
+ * Used by the pinned-dispatcher path (web-fetch + web-search) to
307
+ * RE-VALIDATE the address actually pinned into `connect.lookup` AFTER
308
+ * the second DNS round-trip. Without this check the original SSRF
309
+ * guard's lookup answers can diverge from the lookup answers that
310
+ * feed the pin (hostile resolver flips public→private between calls);
311
+ * re-checking the pinned literal closes that window.
312
+ *
313
+ * Exported for spec coverage.
314
+ */
315
+ export function validateIpLiteralForFetch(address, family) {
316
+ if (!address)
317
+ return 'empty address';
318
+ // Trust family hint when present (LookupAddress.family is 4 or 6),
319
+ // otherwise infer from the string shape.
320
+ const isV4 = family === 4 || (family === undefined && isIPv4(address));
321
+ const isV6 = family === 6 || (family === undefined && isIPv6(address));
322
+ if (isV4) {
323
+ if (ipv4IsBlocked(address)) {
324
+ return `IP ${address} is in a blocked range (SSRF guard)`;
325
+ }
326
+ return null;
327
+ }
328
+ if (isV6) {
329
+ if (ipv6IsBlocked(address)) {
330
+ return `IPv6 ${address} is in a blocked range (SSRF guard)`;
331
+ }
332
+ return null;
333
+ }
334
+ return `address ${address} is not a recognized IPv4/IPv6 literal`;
335
+ }
234
336
  /**
235
337
  * Resolve `hostname` via dns.lookup and reject if any answer maps to
236
338
  * a private/loopback/link-local/CGNAT range. Returns `null` on success
@@ -395,10 +497,34 @@ export async function webFetchTool(input, ctx) {
395
497
  let currentUrl = parsedUrl;
396
498
  let hops = 0;
397
499
  const controller = new AbortController();
500
+ // β1b #62: per-hop pinned Agent so the post-lookup connect(2) cannot
501
+ // be redirected to a private IP by a hostile resolver. Built lazily
502
+ // per hop because each redirect target may resolve to a different
503
+ // host. `undefined` falls back to the global dispatcher (spec
504
+ // MockAgent or production default), preserving the existing test
505
+ // path. The current Agent is closed at end-of-call so we do not leak
506
+ // open connections.
507
+ let activeAgent;
508
+ const closeActiveAgent = async () => {
509
+ if (activeAgent) {
510
+ try {
511
+ await activeAgent.close();
512
+ }
513
+ catch {
514
+ /* ignore — agent already closed */
515
+ }
516
+ activeAgent = undefined;
517
+ }
518
+ };
398
519
  try {
399
520
  while (true) {
521
+ // β1b #62: refresh the pinned Agent for the current hop.
522
+ await closeActiveAgent();
523
+ const hopHost = currentUrl.hostname.replace(/^\[|\]$/g, '');
524
+ activeAgent = await buildPinnedDispatcher(hopHost);
400
525
  response = await request(currentUrl.toString(), {
401
526
  method: 'GET',
527
+ ...(activeAgent ? { dispatcher: activeAgent } : {}),
402
528
  headers: {
403
529
  'user-agent': USER_AGENT,
404
530
  accept: 'text/html,application/xhtml+xml',
@@ -436,6 +562,7 @@ export async function webFetchTool(input, ctx) {
436
562
  /* socket already closed — nothing to do */
437
563
  }
438
564
  }
565
+ await closeActiveAgent();
439
566
  return { ok: false, error: `Exceeded ${MAX_REDIRECTS} redirect hops.` };
440
567
  }
441
568
  // Drain prior body so the socket can be reused.
@@ -445,9 +572,11 @@ export async function webFetchTool(input, ctx) {
445
572
  nextUrl = new URL(locStr, currentUrl);
446
573
  }
447
574
  catch {
575
+ await closeActiveAgent();
448
576
  return { ok: false, error: `Invalid redirect target: ${locStr}` };
449
577
  }
450
578
  if (nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:') {
579
+ await closeActiveAgent();
451
580
  return {
452
581
  ok: false,
453
582
  error: `Refusing redirect to unsupported scheme ${nextUrl.protocol}.`,
@@ -456,6 +585,7 @@ export async function webFetchTool(input, ctx) {
456
585
  const nextHost = nextUrl.hostname.replace(/^\[|\]$/g, '');
457
586
  const guard = await validateHostnameForFetch(nextHost);
458
587
  if (guard) {
588
+ await closeActiveAgent();
459
589
  return { ok: false, error: `SSRF refused on redirect: ${guard}` };
460
590
  }
461
591
  currentUrl = nextUrl;
@@ -465,13 +595,23 @@ export async function webFetchTool(input, ctx) {
465
595
  }
466
596
  }
467
597
  catch (error) {
598
+ await closeActiveAgent();
468
599
  const message = error instanceof Error ? error.message : String(error);
600
+ // β1b r1: the pinned-dispatcher path throws `ssrf_pinned_address_blocked: …`
601
+ // when the second DNS lookup answered a private IP. Surface that as a
602
+ // first-class SSRF refusal so callers (and specs) can match on it
603
+ // without grovelling through `Fetch failed:` prefixes.
604
+ if (message.startsWith('ssrf_pinned_address_blocked')) {
605
+ return { ok: false, error: `SSRF refused: ${message}` };
606
+ }
469
607
  return { ok: false, error: `Fetch failed: ${message}` };
470
608
  }
471
609
  if (!response) {
610
+ await closeActiveAgent();
472
611
  return { ok: false, error: 'No response received.' };
473
612
  }
474
613
  if (response.statusCode < 200 || response.statusCode >= 300) {
614
+ await closeActiveAgent();
475
615
  return { ok: false, error: `HTTP ${response.statusCode} from ${currentUrl.toString()}` };
476
616
  }
477
617
  // content-length is advisory — never trust it for the size cap, but
@@ -489,6 +629,7 @@ export async function webFetchTool(input, ctx) {
489
629
  catch {
490
630
  /* ignore */
491
631
  }
632
+ await closeActiveAgent();
492
633
  return {
493
634
  ok: false,
494
635
  error: `Declared content-length ${n} exceeds ${MAX_RESPONSE_BYTES} byte cap.`,
@@ -499,11 +640,14 @@ export async function webFetchTool(input, ctx) {
499
640
  const contentType = Array.isArray(contentTypeRaw) ? contentTypeRaw[0] : contentTypeRaw;
500
641
  const mime = typeof contentType === 'string' ? contentType.split(';')[0]?.trim().toLowerCase() ?? '' : '';
501
642
  if (!ALLOWED_CONTENT_TYPES.includes(mime)) {
643
+ await closeActiveAgent();
502
644
  return { ok: false, error: `Disallowed content-type ${mime || '(none)'}; only HTML/XHTML/text.` };
503
645
  }
504
646
  const bodyResult = await readBodyWithCap(response.body, controller);
505
- if (!bodyResult.ok)
647
+ if (!bodyResult.ok) {
648
+ await closeActiveAgent();
506
649
  return bodyResult;
650
+ }
507
651
  const html = bodyResult.buffer.toString('utf8');
508
652
  // linkedom is the lightweight DOM Readability needs; jsdom would
509
653
  // add ~3 MB to the install footprint for the same surface.
@@ -524,6 +668,7 @@ export async function webFetchTool(input, ctx) {
524
668
  `Source: ${safeSource}\n\n` +
525
669
  `${scrubbedMarkdown}\n` +
526
670
  `</untrusted-content-${nonce}>`;
671
+ await closeActiveAgent();
527
672
  return {
528
673
  ok: true,
529
674
  url: currentUrl.toString(),