@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.14
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/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/engine/anvil-client.js +99 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +859 -269
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +105 -15
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +10 -4
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- 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
|
package/dist/tools/registry.js
CHANGED
|
@@ -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 },
|
package/dist/tools/web-fetch.js
CHANGED
|
@@ -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(),
|