@crewhaus/tool-notify 0.7.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/net.js ADDED
@@ -0,0 +1,1131 @@
1
+ /**
2
+ * The outbound posture for `@crewhaus/tool-notify`.
3
+ *
4
+ * Every byte this package sends — a chat post, an SMTP session, a webhook,
5
+ * an SMS — leaves through this module. The gate is the one
6
+ * `@crewhaus/tool-http` established, carried over rather than re-derived,
7
+ * because a second outbound surface with a weaker gate is the same hole
8
+ * twice:
9
+ *
10
+ * 1. Empty allow-list ⇒ deny all. There is no "allow everything" value,
11
+ * for origins, for SMTP hosts, or for email recipients.
12
+ * 2. Scheme must be http or https.
13
+ * 3. Origin (scheme + lowercase host + non-default port) must match an
14
+ * allow-list entry exactly, after canonicalisation.
15
+ * 4. SSRF: loopback, link-local (incl. the cloud metadata address),
16
+ * RFC1918, CGNAT, multicast, reserved and mDNS targets are refused even
17
+ * when the host is allow-listed — as an IP literal in any of its
18
+ * encodings, and as the DNS-resolved address. Classification is
19
+ * numeric, never a string prefix, and the ranges that carry an IPv4
20
+ * address (IPv4-mapped, NAT64, 6to4) are judged by the address they
21
+ * carry.
22
+ * 5. The vetted IP is pinned for the actual connection, so a rebinding
23
+ * resolver cannot swap in a private address between the check and the
24
+ * socket (CWE-367). The SMTP client pins the same way.
25
+ * 6. A send never follows a redirect. A 3xx on a POST is reported as a
26
+ * failure, because the alternatives — replaying the body at the new
27
+ * origin, or silently downgrading to a GET that delivers nothing — are
28
+ * both worse than telling the caller to point the allow-list at the
29
+ * real endpoint. Only the read-only `DeliveryCheck` follows, and then
30
+ * the allow-list, the SSRF gate and the credential rules run again on
31
+ * every hop.
32
+ * 7. Credentials are environment variable NAMES, never values. Whatever
33
+ * header a profile sets is dropped the moment a redirect leaves the
34
+ * origin it was minted for, and every result — success or failure —
35
+ * goes through a redactor built from the resolved secret.
36
+ * 8. Every request is deadline-bounded and every response is byte-capped,
37
+ * with the cap bounding memory rather than applied after buffering.
38
+ *
39
+ * What it does NOT do, so nobody assumes otherwise: no proxy support, no
40
+ * cookie jar, and no certificate handling beyond the runtime's own.
41
+ */
42
+ import { Buffer } from "node:buffer";
43
+ import { lookup as dnsLookup, resolveTxt } from "node:dns/promises";
44
+ import { CrewhausError } from "@crewhaus/errors";
45
+ import { joinTxtChunks, normalizeDomain } from "./lib/dns-records";
46
+ /** Refusal by the allow-list, the SSRF gate, or a redirect rule. */
47
+ export class NotifyPermissionError extends CrewhausError {
48
+ name = "NotifyPermissionError";
49
+ constructor(message) {
50
+ super("tool", message);
51
+ }
52
+ }
53
+ export const DEFAULT_TIMEOUT_MS = 30_000;
54
+ export const MAX_TIMEOUT_MS = 300_000;
55
+ export const DEFAULT_MAX_BYTES = 256 * 1024;
56
+ export const MAX_MAX_BYTES = 4 * 1024 * 1024;
57
+ export const MAX_REDIRECTS = 3;
58
+ /** Compact JSON — every byte returned is a byte in somebody's context window. */
59
+ export const json = (value) => JSON.stringify(value);
60
+ /** Locale-independent string order. `localeCompare` without a locale is not deterministic. */
61
+ export function byString(a, b) {
62
+ return a < b ? -1 : a > b ? 1 : 0;
63
+ }
64
+ const EMPTY_CONFIG = {
65
+ allowedOrigins: new Set(),
66
+ allowedRecipients: [],
67
+ allowedSmtpHosts: new Set(),
68
+ allowedSenderDomains: new Set(),
69
+ providers: new Map(),
70
+ };
71
+ let notifyConfig = EMPTY_CONFIG;
72
+ /** Build a config from a spec-shaped block. Pure; both key spellings accepted. */
73
+ export function buildNotifyConfig(input) {
74
+ const origins = new Set();
75
+ for (const origin of input.allowedOrigins ?? input.allowed_origins ?? []) {
76
+ origins.add(canonicalizeOrigin(origin));
77
+ }
78
+ const recipients = [...(input.allowedRecipients ?? input.allowed_recipients ?? [])]
79
+ .map((r) => r.trim().toLowerCase())
80
+ .filter((r) => r !== "")
81
+ .sort(byString);
82
+ const smtpHosts = new Set();
83
+ for (const host of input.allowedSmtpHosts ?? input.allowed_smtp_hosts ?? []) {
84
+ const trimmed = host.trim().toLowerCase();
85
+ if (trimmed !== "")
86
+ smtpHosts.add(trimmed);
87
+ }
88
+ const senderDomains = new Set();
89
+ for (const domain of input.allowedSenderDomains ?? input.allowed_sender_domains ?? []) {
90
+ if (domain.trim() === "")
91
+ continue;
92
+ // Canonicalised by the SAME function the tool runs on its argument, so
93
+ // an operator writing `München.DE` and a caller writing `xn--mnchen-3ya.de`
94
+ // name the same domain rather than two that never match. A malformed
95
+ // entry throws here rather than sitting in the set matching nothing,
96
+ // which is how a misconfiguration surfaces at boot and not at the first
97
+ // lookup.
98
+ const normalized = normalizeDomain(domain);
99
+ if (!normalized.ok) {
100
+ throw new NotifyPermissionError(`invalid entry in allowed_sender_domains: ${normalized.reason}`);
101
+ }
102
+ senderDomains.add(normalized.name);
103
+ }
104
+ const providers = new Map();
105
+ for (const [name, profile] of Object.entries(input.providers ?? {})) {
106
+ providers.set(name, profile);
107
+ }
108
+ return {
109
+ allowedOrigins: origins,
110
+ allowedRecipients: recipients,
111
+ allowedSmtpHosts: smtpHosts,
112
+ allowedSenderDomains: senderDomains,
113
+ providers,
114
+ };
115
+ }
116
+ /** Replace the process-global allow-list. Codegen calls this at boot. */
117
+ export function registerNotifyConfig(input) {
118
+ notifyConfig = buildNotifyConfig(input);
119
+ }
120
+ export function getNotifyConfig() {
121
+ return notifyConfig;
122
+ }
123
+ /**
124
+ * The config ONE call runs under: the serving candidate's
125
+ * `tool_config.notify` block when it declares one, else the boot
126
+ * registration. A non-object override is ignored rather than widened — the
127
+ * allow-list only ever comes from a spec block.
128
+ */
129
+ export function resolveNotifyConfig(override) {
130
+ if (typeof override === "object" && override !== null && !Array.isArray(override)) {
131
+ return buildNotifyConfig(override);
132
+ }
133
+ return notifyConfig;
134
+ }
135
+ /** Test-only — back to fail-closed empty. */
136
+ export function _resetNotifyConfig() {
137
+ notifyConfig = EMPTY_CONFIG;
138
+ }
139
+ /**
140
+ * Canonicalise an origin for exact-match comparison: lowercase scheme and
141
+ * host, drop path/query/fragment, elide the default port. Throws on a
142
+ * malformed origin so a misconfiguration surfaces at boot, not at the first
143
+ * send.
144
+ */
145
+ export function canonicalizeOrigin(raw) {
146
+ let url;
147
+ try {
148
+ url = new URL(raw);
149
+ }
150
+ catch {
151
+ throw new NotifyPermissionError(`invalid origin "${raw}" — must be an absolute URL`);
152
+ }
153
+ return canonicalizeUrlOrigin(url, `${url.protocol}//${url.host}`);
154
+ }
155
+ /**
156
+ * The origin of a URL already parsed, without putting the URL back into any
157
+ * message. `label` is what a refusal may name — never the full URL, because
158
+ * a Slack or Teams incoming-webhook URL IS the credential, and an error
159
+ * string is a thing that ends up in a transcript.
160
+ */
161
+ export function canonicalizeUrlOrigin(url, label = `${url.protocol}//${url.host}`) {
162
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
163
+ throw new NotifyPermissionError(`invalid origin "${label}" — only http/https schemes are supported`);
164
+ }
165
+ const scheme = url.protocol.toLowerCase();
166
+ const host = url.hostname.toLowerCase();
167
+ if (host === "") {
168
+ throw new NotifyPermissionError(`invalid origin "${label}" — host is required`);
169
+ }
170
+ let port = "";
171
+ if (url.port !== "") {
172
+ const isDefault = (scheme === "http:" && url.port === "80") || (scheme === "https:" && url.port === "443");
173
+ port = isDefault ? "" : `:${url.port}`;
174
+ }
175
+ return `${scheme}//${host}${port}`;
176
+ }
177
+ /**
178
+ * How a webhook URL is allowed to appear in a message: scheme, host and a
179
+ * path length, with the path itself and the query dropped.
180
+ *
181
+ * A Slack incoming webhook (`/services/T…/B…/xxxx`) and a Discord one
182
+ * (`/api/webhooks/<id>/<token>`) both carry their whole authority in the
183
+ * path. Printing the path into a refusal would post the credential into the
184
+ * transcript, so only its shape is printed.
185
+ */
186
+ export function safeUrlLabel(raw) {
187
+ let url;
188
+ try {
189
+ url = typeof raw === "string" ? new URL(raw) : raw;
190
+ }
191
+ catch {
192
+ return "<unparseable url>";
193
+ }
194
+ const pathLen = url.pathname.length + url.search.length;
195
+ return `${url.protocol}//${url.host} (path of ${pathLen} characters, not shown)`;
196
+ }
197
+ /** Refuse a URL that carries `user:pass@`. */
198
+ export function assertNoUserinfo(url) {
199
+ if (url.username !== "" || url.password !== "") {
200
+ throw new NotifyPermissionError(`denied: the URL for "${url.protocol}//${url.host}" carries userinfo (user:password@host) — use the auth profile, which names an environment variable instead of putting the secret in the URL`);
201
+ }
202
+ }
203
+ /** Refuse a URL whose origin is not allow-listed. Empty list ⇒ refuse everything. */
204
+ export function assertOriginAllowed(url, cfg) {
205
+ if (cfg.allowedOrigins.size === 0) {
206
+ throw new NotifyPermissionError(`denied: origin "${url.protocol}//${url.host}" is not in allowed_origins (empty allow-list = deny all)`);
207
+ }
208
+ const canonical = canonicalizeUrlOrigin(url);
209
+ if (!cfg.allowedOrigins.has(canonical)) {
210
+ throw new NotifyPermissionError(`denied: origin "${canonical}" is not in allowed_origins`);
211
+ }
212
+ }
213
+ /** Refuse an SMTP host no operator named. Empty list ⇒ refuse everything. */
214
+ export function assertSmtpHostAllowed(host, cfg) {
215
+ const lower = host.trim().toLowerCase();
216
+ if (cfg.allowedSmtpHosts.size === 0) {
217
+ throw new NotifyPermissionError(`denied: SMTP host "${host}" is not in allowed_smtp_hosts (empty allow-list = deny all)`);
218
+ }
219
+ if (!cfg.allowedSmtpHosts.has(lower)) {
220
+ throw new NotifyPermissionError(`denied: SMTP host "${host}" is not in allowed_smtp_hosts`);
221
+ }
222
+ }
223
+ /**
224
+ * Refuse a domain no operator named. Empty list ⇒ refuse everything.
225
+ *
226
+ * `domain` must ALREADY be the canonical form — the caller normalises once
227
+ * and then uses that one value for the gate, for the names it derives and
228
+ * for what it reports, so there is no spelling that passes the check and a
229
+ * different one that reaches the resolver.
230
+ */
231
+ export function assertSenderDomainAllowed(domain, cfg) {
232
+ if (cfg.allowedSenderDomains.size === 0) {
233
+ throw new NotifyPermissionError(`denied: "${domain}" is not in allowed_sender_domains (empty allow-list = deny all). An operator lists the domains this harness may ask public DNS about`);
234
+ }
235
+ if (!cfg.allowedSenderDomains.has(domain)) {
236
+ throw new NotifyPermissionError(`denied: "${domain}" is not in allowed_sender_domains`);
237
+ }
238
+ }
239
+ /**
240
+ * Does `address` match an allow-list entry?
241
+ *
242
+ * An entry is either a whole address or `*@domain`. A bare domain is NOT
243
+ * accepted as a wildcard: `example.com` would read to a human as "anyone at
244
+ * example.com" and to this function as "the address example.com", and a gate
245
+ * whose entries mean one thing to the operator and another to the code is
246
+ * not a gate. Matching is case-insensitive on the whole address, which is
247
+ * looser than RFC 5321 (local-parts are technically case-sensitive) and the
248
+ * looseness is in the safe direction only for the domain — so a deliberately
249
+ * case-varied local part cannot slip a DIFFERENT mailbox past an entry,
250
+ * because the entry had to name that mailbox's spelling in the first place.
251
+ */
252
+ export function recipientAllowed(address, cfg) {
253
+ const lower = address.trim().toLowerCase();
254
+ const at = lower.lastIndexOf("@");
255
+ if (at <= 0 || at === lower.length - 1)
256
+ return false;
257
+ const domain = lower.slice(at + 1);
258
+ for (const entry of cfg.allowedRecipients) {
259
+ if (entry === lower)
260
+ return true;
261
+ if (entry.startsWith("*@") && entry.slice(2) === domain)
262
+ return true;
263
+ }
264
+ return false;
265
+ }
266
+ const defaultDnsLookup = (host) => dnsLookup(host, { verbatim: false });
267
+ let dnsLookupFn = defaultDnsLookup;
268
+ /** Test-only resolver injection, as in `@crewhaus/tool-http`. */
269
+ export function _setDnsLookup(fn) {
270
+ dnsLookupFn = fn ?? defaultDnsLookup;
271
+ }
272
+ const defaultDnsTxt = (name) => resolveTxt(name);
273
+ let dnsTxtFn = defaultDnsTxt;
274
+ /** Test-only resolver injection. No test in this package may reach real DNS. */
275
+ export function _setDnsTxtResolver(fn) {
276
+ dnsTxtFn = fn ?? defaultDnsTxt;
277
+ }
278
+ /**
279
+ * What this package will read back from a name, and report.
280
+ *
281
+ * A DKIM key at 4096 bits is about 800 characters, so these are generous;
282
+ * what they stop is an answer nobody can use ending up in a transcript, a
283
+ * trace and an eval report. The bound is on what is REPORTED — the resolver
284
+ * has already buffered whatever it received by the time this sees it, which
285
+ * is the one place in this package where a cap cannot come first.
286
+ */
287
+ const MAX_TXT_RECORDS = 32;
288
+ const MAX_TXT_RECORD_CHARS = 8192;
289
+ /**
290
+ * Race a lookup against the call's deadline.
291
+ *
292
+ * `dns.promises` takes no signal, so the deadline is applied here instead.
293
+ * The losing lookup is left to finish on its own — its rejection is already
294
+ * attached below, so it cannot surface later as an unhandled one.
295
+ */
296
+ function withDeadline(work, deadline) {
297
+ return new Promise((resolve, reject) => {
298
+ const signal = deadline.signal;
299
+ const fail = () => reject(new Error("aborted"));
300
+ if (signal.aborted) {
301
+ fail();
302
+ return;
303
+ }
304
+ const onAbort = () => fail();
305
+ signal.addEventListener("abort", onAbort, { once: true });
306
+ work.then((value) => {
307
+ signal.removeEventListener("abort", onAbort);
308
+ resolve(value);
309
+ }, (err) => {
310
+ signal.removeEventListener("abort", onAbort);
311
+ reject(err instanceof Error ? err : new Error(String(err)));
312
+ });
313
+ });
314
+ }
315
+ /**
316
+ * Ask for the TXT records at `name`.
317
+ *
318
+ * The two codes that ARE answers are mapped to answers: c-ares reports
319
+ * NXDOMAIN as `ENOTFOUND` and "the name exists but has no record of this
320
+ * type" as `ENODATA`, and both of those genuinely mean nothing is published.
321
+ * Everything else — SERVFAIL, a refused query, a timeout, a resolver that is
322
+ * not reachable — is `unknown` WITH the reason, because a lookup that did
323
+ * not happen must not read as a domain that published nothing.
324
+ */
325
+ export async function lookupTxt(name, deadline) {
326
+ let raw;
327
+ try {
328
+ raw = await withDeadline(dnsTxtFn(name), deadline);
329
+ }
330
+ catch (err) {
331
+ if (deadline.expired()) {
332
+ return { outcome: "unknown", reason: `the lookup of "${name}" hit the deadline` };
333
+ }
334
+ const code = err.code;
335
+ if (code === "ENOTFOUND")
336
+ return { outcome: "nxdomain" };
337
+ if (code === "ENODATA")
338
+ return { outcome: "none" };
339
+ const detail = err instanceof Error ? err.message : String(err);
340
+ return {
341
+ outcome: "unknown",
342
+ reason: `the lookup of "${name}" failed: ${code === undefined ? detail : code}`,
343
+ };
344
+ }
345
+ // Answer order is not stable — a resolver may rotate records between
346
+ // queries — so the list is sorted before anybody reads it, and a repeated
347
+ // call reports the same bytes. Order WITHIN a record is untouched: an SPF
348
+ // record's terms are evaluated left to right and mean different things
349
+ // rearranged.
350
+ const records = raw.map((chunks) => joinTxtChunks([...chunks])).sort(byString);
351
+ // Over the cap is `unknown`, never a truncated record: a parser handed the
352
+ // first 8192 characters of an SPF record would report the `-all` it did
353
+ // not see as missing, which is a wrong answer where this is a missing one.
354
+ if (records.length > MAX_TXT_RECORDS) {
355
+ return {
356
+ outcome: "unknown",
357
+ reason: `"${name}" has ${records.length} TXT records, more than the ${MAX_TXT_RECORDS} this tool will read`,
358
+ };
359
+ }
360
+ const oversized = records.find((record) => record.length > MAX_TXT_RECORD_CHARS);
361
+ if (oversized !== undefined) {
362
+ return {
363
+ outcome: "unknown",
364
+ reason: `"${name}" has a TXT record of ${oversized.length} characters, over the ${MAX_TXT_RECORD_CHARS} this tool will read`,
365
+ };
366
+ }
367
+ return records.length === 0 ? { outcome: "none" } : { outcome: "records", records };
368
+ }
369
+ /**
370
+ * TEST-ONLY escape hatch for the private-address gate.
371
+ *
372
+ * The tests in this package drive the tools against a real `Bun.serve` and a
373
+ * real SMTP listener on 127.0.0.1, because mocking `fetch` would prove
374
+ * nothing about whether a deadline, a byte cap or an SMTP handshake actually
375
+ * works. Reaching a real local server means the loopback refusal has to be
376
+ * lifted for the duration of a test, and this is the only thing that lifts
377
+ * it.
378
+ *
379
+ * It is deliberately NOT reachable from a spec: no `tool_config` key sets it,
380
+ * nothing exported to the catalog calls it, and it defaults to `false` on
381
+ * every process start.
382
+ *
383
+ * It lifts LOOPBACK ONLY. The tests need 127.0.0.1 and `::1`; they have never
384
+ * needed 169.254.169.254, an RFC1918 address or a `.local` name, so flipping
385
+ * this flag must not open the cloud metadata endpoint or the machine's LAN.
386
+ */
387
+ let privateHostsAllowedForTest = false;
388
+ export function __setPrivateHostsAllowedForTest(allowed) {
389
+ privateHostsAllowedForTest = allowed;
390
+ }
391
+ /** True for the loopback addresses, and only those. */
392
+ function isLoopback(ip) {
393
+ const bare = ip.trim().replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
394
+ if (bare.includes(":")) {
395
+ const groups = expandIpv6(bare);
396
+ if (groups === null)
397
+ return false;
398
+ const mapped = groups.slice(0, 5).every((g) => g === 0) && (groups[5] === 0 || groups[5] === 0xffff);
399
+ if (mapped) {
400
+ if (groups[6] === 0 && groups[7] === 1)
401
+ return true; // ::1
402
+ const hi = groups[6];
403
+ const lo = groups[7];
404
+ return isLoopback([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
405
+ }
406
+ return false;
407
+ }
408
+ return normalizeIpv4(bare)?.startsWith("127.") === true;
409
+ }
410
+ /**
411
+ * Reject a host that is — or resolves to — loopback, link-local, RFC1918,
412
+ * CGNAT, or mDNS. Returns the vetted IP so the caller can pin the socket to
413
+ * it instead of letting the stack re-resolve at connect time.
414
+ */
415
+ export async function assertNotSsrf(hostname) {
416
+ const lower = hostname.toLowerCase();
417
+ const unbracketed = lower.replace(/^\[/, "").replace(/\]$/, "");
418
+ /** The flag lifts loopback and nothing else — see its declaration. */
419
+ const refused = (ip) => isPrivateIp(ip) && !(privateHostsAllowedForTest && isLoopback(ip));
420
+ if (lower === "localhost" || lower.endsWith(".localhost")) {
421
+ if (!privateHostsAllowedForTest) {
422
+ throw new NotifyPermissionError(`SSRF: host "${hostname}" resolves to loopback`);
423
+ }
424
+ }
425
+ else if (lower.endsWith(".local")) {
426
+ throw new NotifyPermissionError(`SSRF: mDNS host "${hostname}" is not allowed`);
427
+ }
428
+ else if (refused(unbracketed)) {
429
+ throw new NotifyPermissionError(`SSRF: host "${hostname}" is a private/loopback IP`);
430
+ }
431
+ // An IP literal is its own pinned target — no lookup, nothing to rebind. An
432
+ // IPv6 literal is re-emitted in expanded form so the address that is pinned
433
+ // is the address that was classified, not a spelling of it.
434
+ const expanded = unbracketed.includes(":") ? expandIpv6(unbracketed) : null;
435
+ const literal = normalizeIpv4(unbracketed) ??
436
+ (expanded === null ? null : expanded.map((g) => g.toString(16)).join(":"));
437
+ if (literal !== null)
438
+ return literal;
439
+ if (unbracketed.includes(":")) {
440
+ throw new NotifyPermissionError(`SSRF: host "${hostname}" is not a valid IPv6 address`);
441
+ }
442
+ let resolved;
443
+ try {
444
+ resolved = await dnsLookupFn(lower);
445
+ }
446
+ catch (err) {
447
+ const msg = err instanceof Error ? err.message : String(err);
448
+ throw new NotifyPermissionError(`SSRF: cannot resolve "${hostname}": ${msg}`);
449
+ }
450
+ if (refused(resolved.address)) {
451
+ throw new NotifyPermissionError(`SSRF: host "${hostname}" resolves to private IP ${resolved.address}`);
452
+ }
453
+ return resolved.address;
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // BEGIN SYNCHRONISED BLOCK — private-address classifier
457
+ //
458
+ // This block is BYTE-IDENTICAL across every package that guards an outbound
459
+ // request. Do not edit one copy: `apps/cli/src/tool-registry.test.ts` hashes
460
+ // them all and fails if any differs, and it asserts how many it found, because
461
+ // a copy-scanning guard that matches nothing reports green.
462
+ //
463
+ // It exists in copies rather than a shared package because these files are
464
+ // otherwise independent per-package networking layers; a guard proving the
465
+ // copies are identical is cheaper and safer than the import graph a shared
466
+ // package would need across `crawler`, `computer-use-driver` and ten tools.
467
+ //
468
+ // WHY IT PARSES INSTEAD OF MATCHING TEXT. Six copies were confirmed
469
+ // exploitable on 2026-09-18 because they compared address STRINGS. The WHATWG
470
+ // URL parser rewrites `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, so a
471
+ // text check never sees the spelling it was written for; and `64:ff9b::a9fe:a9fe`
472
+ // IS 169.254.169.254 on any network running DNS64/NAT64. Parsing numerically and
473
+ // recursing into the embedded IPv4 is the only form that holds.
474
+ //
475
+ // WHAT IT CANNOT DO. RFC 6052 lets an operator choose any Network-Specific
476
+ // Prefix for NAT64, so an embedded IPv4 behind an arbitrary NSP is undecidable
477
+ // from the address alone. That case is configuration, and the callers that need
478
+ // it resolve the host and re-check the ANSWER before dialling.
479
+ // ---------------------------------------------------------------------------
480
+ /**
481
+ * Canonicalise an IPv4 literal.
482
+ *
483
+ * `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
484
+ * `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
485
+ * only understands dotted-decimal waves every one of them through. Returns
486
+ * `null` when the string is not an IPv4 literal at all.
487
+ */
488
+ export function normalizeIpv4(raw) {
489
+ const trimmed = raw.trim();
490
+ if (trimmed === "" || /[^0-9a-fA-FxX.]/.test(trimmed))
491
+ return null;
492
+ const parts = trimmed.split(".");
493
+ if (parts.length === 0 || parts.length > 4)
494
+ return null;
495
+ const values = [];
496
+ for (const part of parts) {
497
+ if (part === "")
498
+ return null;
499
+ let value;
500
+ if (/^0[xX][0-9a-fA-F]+$/.test(part))
501
+ value = Number.parseInt(part.slice(2), 16);
502
+ else if (/^0[0-7]+$/.test(part))
503
+ value = Number.parseInt(part.slice(1), 8);
504
+ else if (/^\d+$/.test(part))
505
+ value = Number.parseInt(part, 10);
506
+ else
507
+ return null;
508
+ if (!Number.isFinite(value) || value < 0)
509
+ return null;
510
+ values.push(value);
511
+ }
512
+ // The short forms pack the remaining octets into the last part: `127.1` is
513
+ // 127.0.0.1, not 127.1.0.0. Getting this backwards is how a bypass survives.
514
+ const last = values[values.length - 1];
515
+ const leading = values.slice(0, -1);
516
+ if (leading.some((v) => v > 255))
517
+ return null;
518
+ const remaining = 4 - leading.length;
519
+ if (last >= 2 ** (8 * remaining))
520
+ return null;
521
+ const octets = [...leading];
522
+ for (let i = remaining - 1; i >= 0; i--)
523
+ octets.push((last >>> (8 * i)) & 0xff);
524
+ return octets.join(".");
525
+ }
526
+ /**
527
+ * Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
528
+ * is not one.
529
+ *
530
+ * Classifying IPv6 by its TEXT is where the bypasses live, because one address
531
+ * has many spellings and the one a check was written against is rarely the one
532
+ * that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
533
+ * that form: the WHATWG URL parser re-serialises the embedded quad as hex
534
+ * pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
535
+ * first means the prefix tests below are arithmetic, and spelling stops
536
+ * mattering.
537
+ */
538
+ export function parseIpv6(raw) {
539
+ let text = raw.trim().toLowerCase();
540
+ if (text.startsWith("["))
541
+ text = text.slice(1);
542
+ if (text.endsWith("]"))
543
+ text = text.slice(0, -1);
544
+ const zone = text.indexOf("%"); // fe80::1%eth0
545
+ if (zone !== -1)
546
+ text = text.slice(0, zone);
547
+ if (!text.includes(":"))
548
+ return null;
549
+ // A trailing dotted quad is the last two groups written in IPv4. Require all
550
+ // three dots: without that, `::1` parses as a one-part inet_aton address and
551
+ // takes a path that has nothing to do with what was written.
552
+ const dotted = /^(.*:)(\d+(?:\.\d+){3})$/.exec(text);
553
+ if (dotted !== null) {
554
+ const quad = normalizeIpv4(dotted[2]);
555
+ if (quad === null)
556
+ return null;
557
+ const o = quad.split(".").map((n) => Number.parseInt(n, 10));
558
+ const hi = (o[0] << 8) | o[1];
559
+ const lo = (o[2] << 8) | o[3];
560
+ text = `${dotted[1]}${hi.toString(16)}:${lo.toString(16)}`;
561
+ }
562
+ const halves = text.split("::");
563
+ if (halves.length > 2)
564
+ return null;
565
+ const pieces = (part) => (part === "" ? [] : part.split(":"));
566
+ const head = pieces(halves[0]);
567
+ const tail = halves.length === 2 ? pieces(halves[1]) : [];
568
+ if (halves.length === 1 && head.length !== 8)
569
+ return null;
570
+ if (head.length + tail.length > 8)
571
+ return null;
572
+ const groups = [];
573
+ for (const piece of head) {
574
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
575
+ return null;
576
+ groups.push(Number.parseInt(piece, 16));
577
+ }
578
+ for (let i = head.length + tail.length; i < 8; i++)
579
+ groups.push(0);
580
+ for (const piece of tail) {
581
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
582
+ return null;
583
+ groups.push(Number.parseInt(piece, 16));
584
+ }
585
+ return groups.length === 8 ? groups : null;
586
+ }
587
+ /**
588
+ * The IPv4 address an IPv6 address carries, when it carries one.
589
+ *
590
+ * Every transition mechanism embeds a v4 address somewhere, and every one of
591
+ * them is a way to reach a v4 destination while wearing a v6 spelling that no
592
+ * v4 range check looks at. The NAT64 well-known prefix is the sharpest: a
593
+ * DNS64 resolver answers an IPv4-only name with `64:ff9b::<the v4>`, so
594
+ * `64:ff9b::a9fe:a9fe` IS the metadata service on any network that runs one.
595
+ */
596
+ function embeddedIpv4(g) {
597
+ const quad = (hi, lo) => `${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`;
598
+ const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
599
+ const last = quad(g[6], g[7]);
600
+ if (zeros(0, 5) && g[5] === 0xffff)
601
+ return last; // ::ffff:0:0/96, IPv4-mapped
602
+ if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0)
603
+ return last; // ::ffff:0:0:0/96, translated
604
+ if (g[0] === 0x64 && g[1] === 0xff9b)
605
+ return last; // 64:ff9b::/96 and 64:ff9b:1::/48, NAT64
606
+ if (g[0] === 0x2002)
607
+ return quad(g[1], g[2]); // 2002::/16, 6to4
608
+ // ::a.b.c.d, IPv4-compatible: deprecated, still routed by some stacks, and
609
+ // not to be confused with `::` or `::1`, which are handled before this.
610
+ if (zeros(0, 6))
611
+ return last;
612
+ return null;
613
+ }
614
+ /** Ranges that are never a public RPC endpoint. */
615
+ export function isPrivateIp(address) {
616
+ const v4 = normalizeIpv4(address);
617
+ if (v4 !== null) {
618
+ const [a = 0, b = 0, c = 0, d = 0] = v4.split(".").map((n) => Number.parseInt(n, 10));
619
+ if (a === 0 || a === 10 || a === 127)
620
+ return true;
621
+ if (a === 169 && b === 254)
622
+ return true; // link-local, and the metadata service
623
+ if (a === 172 && b >= 16 && b <= 31)
624
+ return true;
625
+ if (a === 192 && b === 168)
626
+ return true;
627
+ if (a === 192 && b === 0 && c === 0)
628
+ return true;
629
+ if (a === 100 && b >= 64 && b <= 127)
630
+ return true; // carrier-grade NAT
631
+ if (a === 198 && (b === 18 || b === 19))
632
+ return true; // benchmarking
633
+ if (a >= 224)
634
+ return true; // multicast, reserved, broadcast
635
+ return a === 255 && b === 255 && c === 255 && d === 255;
636
+ }
637
+ const groups = parseIpv6(address);
638
+ if (groups === null)
639
+ return false;
640
+ if (groups.every((g) => g === 0))
641
+ return true; // ::, the unspecified address
642
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
643
+ return true; // ::1
644
+ const carried = embeddedIpv4(groups);
645
+ if (carried !== null)
646
+ return isPrivateIp(carried);
647
+ const head = groups[0];
648
+ if ((head & 0xfe00) === 0xfc00)
649
+ return true; // fc00::/7, unique-local
650
+ if ((head & 0xffc0) === 0xfe80)
651
+ return true; // fe80::/10, link-local
652
+ return (head & 0xff00) === 0xff00; // ff00::/8, multicast
653
+ }
654
+ // END SYNCHRONISED BLOCK
655
+ /**
656
+ * `parseIpv6` under this package's older name, returning a mutable array.
657
+ *
658
+ * Deliberately OUTSIDE the synchronised block: `smtp.ts` uses it to decide
659
+ * whether a host is an IP literal and so has no SNI name, the SSRF gate
660
+ * re-emits an IPv6 literal in expanded form from it, and `lib.test.ts`
661
+ * imports it. The block's own text must not change, so the adaptation to
662
+ * this package's older name and signature lives here instead.
663
+ */
664
+ export function expandIpv6(raw) {
665
+ const groups = parseIpv6(raw);
666
+ return groups === null ? null : [...groups];
667
+ }
668
+ /**
669
+ * A deadline that also honours the runtime's own cancellation. Every
670
+ * outbound tool here opens one before its first byte and cancels it in a
671
+ * `finally` — a tool that can hang forever is a defect.
672
+ */
673
+ export function startDeadline(ms, outer) {
674
+ const ctrl = new AbortController();
675
+ const startedAt = Date.now();
676
+ const timer = setTimeout(() => ctrl.abort(new Error(`deadline of ${ms}ms elapsed`)), ms);
677
+ const onOuter = () => ctrl.abort(outer?.reason);
678
+ if (outer !== undefined) {
679
+ if (outer.aborted)
680
+ ctrl.abort(outer.reason);
681
+ else
682
+ outer.addEventListener("abort", onOuter, { once: true });
683
+ }
684
+ return {
685
+ signal: ctrl.signal,
686
+ remaining: () => Math.max(0, ms - (Date.now() - startedAt)),
687
+ expired: () => Date.now() - startedAt >= ms,
688
+ cancel: () => {
689
+ clearTimeout(timer);
690
+ outer?.removeEventListener("abort", onOuter);
691
+ },
692
+ };
693
+ }
694
+ /** Resolve after `ms`, or as soon as `signal` aborts. Never rejects. */
695
+ export function sleep(ms, signal) {
696
+ return new Promise((resolve) => {
697
+ if (signal?.aborted === true) {
698
+ resolve();
699
+ return;
700
+ }
701
+ const timer = setTimeout(() => {
702
+ signal?.removeEventListener("abort", onAbort);
703
+ resolve();
704
+ }, ms);
705
+ function onAbort() {
706
+ clearTimeout(timer);
707
+ resolve();
708
+ }
709
+ signal?.addEventListener("abort", onAbort, { once: true });
710
+ });
711
+ }
712
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
713
+ const MAX_ENV_NAME_LENGTH = 64;
714
+ const SECRET_PREFIXES = [
715
+ "xoxb-",
716
+ "xoxp-",
717
+ "xoxa-",
718
+ "xapp-",
719
+ "sk_live_",
720
+ "sk_test_",
721
+ "ghp_",
722
+ "github_pat_",
723
+ "glpat-",
724
+ "whsec_",
725
+ "bearer ",
726
+ "sk-",
727
+ "ac", // Twilio account sids are `AC` + 32 hex, and are paired with a token
728
+ ];
729
+ /** True when a value that arrived as a NAME is really a secret. */
730
+ function looksLikeASecret(value) {
731
+ const lower = value.toLowerCase();
732
+ if (lower.startsWith("ac") && /^ac[0-9a-f]{32}$/.test(lower))
733
+ return true;
734
+ return SECRET_PREFIXES.filter((p) => p !== "ac").some((prefix) => lower.startsWith(prefix));
735
+ }
736
+ /**
737
+ * Read a secret out of the named environment variable.
738
+ *
739
+ * The NAME travels through the tool call; the value never does. A value that
740
+ * is not shaped like an environment variable name, or that carries a known
741
+ * secret prefix, is refused as a pasted credential — and the refusal quotes
742
+ * nothing, because a tool result is a transcript, a trace and usually an
743
+ * eval report.
744
+ */
745
+ export function resolveSecret(envVar, what, env = process.env) {
746
+ if (envVar === undefined || envVar === "") {
747
+ return {
748
+ ok: false,
749
+ message: `no ${what}: name the environment variable holding it — the secret itself is never accepted as an argument`,
750
+ };
751
+ }
752
+ if (!ENV_NAME.test(envVar) || envVar.length > MAX_ENV_NAME_LENGTH || looksLikeASecret(envVar)) {
753
+ return {
754
+ ok: false,
755
+ message: `${what} must be the NAME of an environment variable (letters, digits and underscores, e.g. SLACK_WEBHOOK_URL), not the secret. The value given is not a usable name and has not been echoed back; if it was the secret itself, treat it as exposed to whoever wrote it and set the variable instead`,
756
+ };
757
+ }
758
+ const value = env[envVar];
759
+ if (value === undefined || value === "") {
760
+ return {
761
+ ok: false,
762
+ message: `${what} names environment variable "${envVar}", which is unset or empty in this process`,
763
+ };
764
+ }
765
+ return { ok: true, value };
766
+ }
767
+ /** Header names that must never be supplied inline, and never survive a hop. */
768
+ export const CREDENTIAL_HEADERS = new Set([
769
+ "authorization",
770
+ "proxy-authorization",
771
+ "cookie",
772
+ ]);
773
+ /**
774
+ * Refuse caller-supplied credential headers. The `auth` profile is the only
775
+ * way to attach one, which keeps the secret out of the tool arguments and
776
+ * therefore out of the transcript.
777
+ */
778
+ export function rejectInlineCredentials(headers) {
779
+ for (const name of Object.keys(headers)) {
780
+ if (CREDENTIAL_HEADERS.has(name.toLowerCase())) {
781
+ return `header "${name}" cannot be set inline — use the auth profile, which names an environment variable instead of carrying the secret in the tool call`;
782
+ }
783
+ }
784
+ return null;
785
+ }
786
+ /**
787
+ * Apply an auth profile to `headers`. Returns the lowercased names of the
788
+ * headers it set — so a profile that puts the secret in `X-Api-Key` is
789
+ * guarded exactly as hard as one that puts it in `Authorization` — and the
790
+ * resolved secret values, which feed the redactor.
791
+ */
792
+ export function applyAuth(headers, auth, env = process.env) {
793
+ if (auth === undefined)
794
+ return { ok: true, secretHeaders: new Set(), secrets: [] };
795
+ const resolved = resolveSecret(auth.envVar, "auth profile envVar", env);
796
+ if (!resolved.ok)
797
+ return { ok: false, message: resolved.message };
798
+ const secret = resolved.value;
799
+ if (auth.type === "bearer") {
800
+ headers["Authorization"] = `Bearer ${secret}`;
801
+ return { ok: true, secretHeaders: new Set(["authorization"]), secrets: [secret] };
802
+ }
803
+ if (auth.type === "basic") {
804
+ if (auth.username === undefined) {
805
+ return {
806
+ ok: false,
807
+ message: 'auth type "basic" needs a username; the password comes from envVar',
808
+ };
809
+ }
810
+ const encoded = Buffer.from(`${auth.username}:${secret}`, "utf8").toString("base64");
811
+ headers["Authorization"] = `Basic ${encoded}`;
812
+ return { ok: true, secretHeaders: new Set(["authorization"]), secrets: [secret, encoded] };
813
+ }
814
+ if (auth.headerName === undefined) {
815
+ return { ok: false, message: 'auth type "header" needs a headerName' };
816
+ }
817
+ const lower = auth.headerName.toLowerCase();
818
+ if (lower === "host") {
819
+ return { ok: false, message: 'auth type "header" cannot set the Host header' };
820
+ }
821
+ headers[auth.headerName] = `${auth.prefix ?? ""}${secret}`;
822
+ return { ok: true, secretHeaders: new Set([lower]), secrets: [secret] };
823
+ }
824
+ /**
825
+ * A function that scrubs secrets out of anything on its way back to the
826
+ * caller.
827
+ *
828
+ * Everything this package returns — results, refusals, the text of a
829
+ * provider error, an SMTP server's reply — goes through one of these. It is
830
+ * the backstop rather than the primary defence: a secret is only ever placed
831
+ * in a header or an SMTP AUTH line, never in a path or a body. But a
832
+ * provider that echoes a header, a transport error that quotes a request, or
833
+ * an SMTP server that repeats what it was sent would each put the secret in
834
+ * a transcript, and that is not a mistake worth leaving one layer deep.
835
+ *
836
+ * Secrets shorter than six characters are left alone: replacing every "x" in
837
+ * a result would mangle it without protecting anything real.
838
+ */
839
+ export function redactorFor(secrets) {
840
+ const forms = new Set();
841
+ for (const secret of secrets) {
842
+ if (secret === undefined || secret.length < 6)
843
+ continue;
844
+ forms.add(secret);
845
+ forms.add(encodeURIComponent(secret));
846
+ try {
847
+ forms.add(Buffer.from(secret, "utf8").toString("base64"));
848
+ }
849
+ catch {
850
+ // not encodable — the literal form is still covered
851
+ }
852
+ }
853
+ const ordered = [...forms].filter((f) => f.length >= 6).sort((a, b) => b.length - a.length);
854
+ if (ordered.length === 0)
855
+ return (text) => text;
856
+ return (text) => {
857
+ let out = text;
858
+ for (const form of ordered)
859
+ out = out.split(form).join("<redacted>");
860
+ return out;
861
+ };
862
+ }
863
+ /** Response headers as a sorted plain object, credentials removed. */
864
+ const STRIPPED_RESPONSE_HEADERS = new Set([
865
+ "authorization",
866
+ "cookie",
867
+ "set-cookie",
868
+ ]);
869
+ export function responseHeaders(res) {
870
+ const pairs = [];
871
+ for (const [key, value] of res.headers.entries()) {
872
+ if (STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase()))
873
+ continue;
874
+ pairs.push([key.toLowerCase(), value]);
875
+ }
876
+ pairs.sort((a, b) => byString(a[0], b[0]));
877
+ return Object.fromEntries(pairs);
878
+ }
879
+ /**
880
+ * Dial the vetted IP while keeping the real hostname for the `Host` header
881
+ * and TLS SNI, so virtual hosting and certificate validation still work.
882
+ */
883
+ function pinnedFetch(req, pinnedIp) {
884
+ const original = new URL(req.url);
885
+ const host = original.hostname;
886
+ const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
887
+ if (unbracketed === pinnedIp || pinnedIp === "")
888
+ return globalThis.fetch(req);
889
+ const pinnedUrl = new URL(original.toString());
890
+ pinnedUrl.hostname = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp;
891
+ const headers = new Headers(req.headers);
892
+ headers.set("host", original.port === "" ? host : `${host}:${original.port}`);
893
+ const init = {
894
+ method: req.method,
895
+ headers,
896
+ redirect: "manual",
897
+ signal: req.signal,
898
+ tls: { serverName: host },
899
+ };
900
+ if (req.method !== "GET" && req.method !== "HEAD") {
901
+ init.body = req.body;
902
+ init.duplex = "half";
903
+ }
904
+ return globalThis.fetch(pinnedUrl.toString(), init);
905
+ }
906
+ let rawFetch = pinnedFetch;
907
+ export function _setRawFetch(fn) {
908
+ rawFetch = fn ?? pinnedFetch;
909
+ }
910
+ /**
911
+ * Issue a request. Redirects, when followed at all, are followed by hand so
912
+ * the allow-list, the SSRF gate and the credential rules run on every single
913
+ * hop. The response body is left unread — the caller decides how to drain it
914
+ * under which cap.
915
+ */
916
+ export async function openRequest(o) {
917
+ const policy = o.redirect ?? "refuse";
918
+ const limit = o.maxRedirects ?? MAX_REDIRECTS;
919
+ const headers = { ...o.headers };
920
+ const isCredential = (name) => {
921
+ const lower = name.toLowerCase();
922
+ return CREDENTIAL_HEADERS.has(lower) || o.credentialHeaders?.has(lower) === true;
923
+ };
924
+ let redirects = 0;
925
+ let credentialsDropped = false;
926
+ let current = o.url;
927
+ let currentOrigin = canonicalizeUrlOrigin(o.url);
928
+ for (let hop = 0;; hop++) {
929
+ assertNoUserinfo(current);
930
+ assertOriginAllowed(current, o.cfg);
931
+ const pinnedIp = await assertNotSsrf(current.hostname);
932
+ const init = {
933
+ method: o.method,
934
+ redirect: "manual",
935
+ signal: o.signal,
936
+ headers,
937
+ ...(o.body !== undefined && o.method !== "GET" && o.method !== "HEAD"
938
+ ? { body: o.body }
939
+ : {}),
940
+ };
941
+ const res = await rawFetch(new Request(current.toString(), init), pinnedIp);
942
+ const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has("location");
943
+ if (!isRedirect)
944
+ return { res, redirects, credentialsDropped };
945
+ if (policy === "refuse") {
946
+ await discard(res);
947
+ // Deliberately not printing the Location: a redirect target chosen by
948
+ // the far end is content, and this refusal is read by a model.
949
+ throw new NotifyPermissionError(`the endpoint answered ${res.status} with a redirect, which a send never follows — replaying the body at another origin, or downgrading to a GET that delivers nothing, are both worse than stopping. Point the configuration at the endpoint that actually accepts the message.`);
950
+ }
951
+ if (hop >= limit) {
952
+ await discard(res);
953
+ throw new NotifyPermissionError(`too many redirects (>${limit})`);
954
+ }
955
+ const location = res.headers.get("location") ?? "";
956
+ let next;
957
+ try {
958
+ next = new URL(location, current);
959
+ }
960
+ catch {
961
+ await discard(res);
962
+ throw new NotifyPermissionError("the endpoint sent a redirect this tool could not parse");
963
+ }
964
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
965
+ await discard(res);
966
+ throw new NotifyPermissionError(`denied: redirect to scheme "${next.protocol}" — only http/https are allowed`);
967
+ }
968
+ const nextOrigin = canonicalizeUrlOrigin(next);
969
+ if (nextOrigin !== currentOrigin) {
970
+ // The token was minted for the origin we asked, not for wherever it
971
+ // points us next. Drop it before the socket opens — including whatever
972
+ // header the auth profile chose, which is a secret even when it is not
973
+ // called `Authorization`.
974
+ for (const name of Object.keys(headers)) {
975
+ if (isCredential(name)) {
976
+ delete headers[name];
977
+ credentialsDropped = true;
978
+ }
979
+ }
980
+ }
981
+ await discard(res);
982
+ redirects += 1;
983
+ current = next;
984
+ currentOrigin = nextOrigin;
985
+ }
986
+ }
987
+ async function discard(res) {
988
+ try {
989
+ await res.body?.cancel();
990
+ }
991
+ catch {
992
+ // already closed — nothing to release
993
+ }
994
+ }
995
+ /**
996
+ * Drain a body with a hard byte cap, cancelling the stream the moment the cap
997
+ * is passed so a hostile endpoint cannot pin memory. The cap bounds what is
998
+ * ever held, not what is kept after buffering.
999
+ */
1000
+ export async function readCapped(res, maxBytes) {
1001
+ if (res.body === null)
1002
+ return { text: "", bytes: 0, truncated: false };
1003
+ const reader = res.body.getReader();
1004
+ const chunks = [];
1005
+ let total = 0;
1006
+ let truncated = false;
1007
+ try {
1008
+ while (true) {
1009
+ const { done, value } = await reader.read();
1010
+ if (done || value === undefined)
1011
+ break;
1012
+ if (total + value.byteLength > maxBytes) {
1013
+ chunks.push(value.subarray(0, maxBytes - total));
1014
+ total = maxBytes;
1015
+ truncated = true;
1016
+ try {
1017
+ await reader.cancel();
1018
+ }
1019
+ catch {
1020
+ // already aborting
1021
+ }
1022
+ break;
1023
+ }
1024
+ chunks.push(value);
1025
+ total += value.byteLength;
1026
+ }
1027
+ }
1028
+ finally {
1029
+ try {
1030
+ reader.releaseLock();
1031
+ }
1032
+ catch {
1033
+ // already released
1034
+ }
1035
+ }
1036
+ const merged = new Uint8Array(total);
1037
+ let offset = 0;
1038
+ for (const chunk of chunks) {
1039
+ merged.set(chunk, offset);
1040
+ offset += chunk.byteLength;
1041
+ }
1042
+ return {
1043
+ text: new TextDecoder("utf-8", { fatal: false }).decode(merged),
1044
+ bytes: total,
1045
+ truncated,
1046
+ };
1047
+ }
1048
+ /** A URL the caller supplied, or a readable refusal that does not echo it. */
1049
+ export function parseUrl(raw) {
1050
+ try {
1051
+ return new URL(raw);
1052
+ }
1053
+ catch {
1054
+ return "that is not an absolute URL — include the scheme, e.g. https://hooks.example.com/services/… (the value has not been echoed back, in case it was a webhook URL, which is itself a credential)";
1055
+ }
1056
+ }
1057
+ /** Read a dotted path out of a parsed JSON body. `undefined` when absent. */
1058
+ export function readPath(value, path) {
1059
+ let current = value;
1060
+ for (const segment of path.split(".")) {
1061
+ if (segment === "")
1062
+ continue;
1063
+ if (current === null || typeof current !== "object")
1064
+ return undefined;
1065
+ if (Array.isArray(current)) {
1066
+ const index = Number.parseInt(segment, 10);
1067
+ if (!Number.isInteger(index))
1068
+ return undefined;
1069
+ current = current[index];
1070
+ continue;
1071
+ }
1072
+ current = current[segment];
1073
+ }
1074
+ return current;
1075
+ }
1076
+ /**
1077
+ * Turn anything thrown on the outbound path into the one-line string a tool
1078
+ * returns. A refusal, a deadline and a transport failure read differently,
1079
+ * because the caller's next move differs.
1080
+ */
1081
+ export function describeFailure(err, deadline) {
1082
+ if (err instanceof NotifyPermissionError)
1083
+ return err.message;
1084
+ if (deadline?.expired() === true)
1085
+ return "deadline elapsed before the send completed";
1086
+ if (err instanceof Error) {
1087
+ if (err.name === "AbortError")
1088
+ return "the send was aborted before it completed";
1089
+ return `${err.name}: ${err.message}`;
1090
+ }
1091
+ return String(err);
1092
+ }
1093
+ /**
1094
+ * The per-process record of what has already been sent under which key.
1095
+ *
1096
+ * A retry loop that re-runs a tool call is the normal failure mode of an
1097
+ * agent, and "post it again" is the wrong answer when the first post
1098
+ * succeeded. Every sending tool takes an `idempotencyKey`; the key is passed
1099
+ * to the provider when the provider honours one, AND recorded here, so a
1100
+ * second call with the same key returns the first call's result and posts
1101
+ * nothing.
1102
+ *
1103
+ * This is deliberate hidden state, and it is the one place in the package
1104
+ * where a repeated call does not repeat its effect — which is the entire
1105
+ * point. It lives for the life of the process, is capped so a long run
1106
+ * cannot grow it without bound, and is keyed by tool as well as key so two
1107
+ * different tools cannot collide on the same string.
1108
+ */
1109
+ const LEDGER_LIMIT = 512;
1110
+ const ledger = new Map();
1111
+ export function ledgerLookup(tool, key) {
1112
+ if (key === undefined || key === "")
1113
+ return undefined;
1114
+ return ledger.get(`${tool}\u0000${key}`);
1115
+ }
1116
+ export function ledgerRecord(tool, key, result) {
1117
+ if (key === undefined || key === "")
1118
+ return;
1119
+ const id = `${tool}\u0000${key}`;
1120
+ if (ledger.size >= LEDGER_LIMIT && !ledger.has(id)) {
1121
+ // Oldest first — Map preserves insertion order.
1122
+ const oldest = ledger.keys().next();
1123
+ if (!oldest.done)
1124
+ ledger.delete(oldest.value);
1125
+ }
1126
+ ledger.set(id, { tool, result });
1127
+ }
1128
+ /** Test-only — a fresh process's empty ledger. */
1129
+ export function _resetIdempotencyLedger() {
1130
+ ledger.clear();
1131
+ }