@crewhaus/tool-notify 0.7.0 → 0.8.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 CHANGED
@@ -8,7 +8,8 @@
8
8
  * twice:
9
9
  *
10
10
  * 1. Empty allow-list ⇒ deny all. There is no "allow everything" value,
11
- * for origins, for SMTP hosts, or for email recipients.
11
+ * for origins, for SMTP hosts, for email recipients, for SMS numbers or
12
+ * for push targets.
12
13
  * 2. Scheme must be http or https.
13
14
  * 3. Origin (scheme + lowercase host + non-default port) must match an
14
15
  * allow-list entry exactly, after canonicalisation.
@@ -29,10 +30,13 @@
29
30
  * real endpoint. Only the read-only `DeliveryCheck` follows, and then
30
31
  * the allow-list, the SSRF gate and the credential rules run again on
31
32
  * 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.
33
+ * 7. Credentials are environment variable NAMES, never values, and only
34
+ * names the operator listed (`allowed_secret_envs`, or a provider's own
35
+ * `auth.envVar` for that provider): a call chooses among them and can
36
+ * never add one. Whatever header a profile sets is dropped the moment a
37
+ * redirect leaves the origin it was minted for, and every result —
38
+ * success or failure — goes through a redactor built from the resolved
39
+ * secret.
36
40
  * 8. Every request is deadline-bounded and every response is byte-capped,
37
41
  * with the cap bounding memory rather than applied after buffering.
38
42
  *
@@ -40,8 +44,12 @@
40
44
  * cookie jar, and no certificate handling beyond the runtime's own.
41
45
  */
42
46
  import { Buffer } from "node:buffer";
47
+ import { createHash } from "node:crypto";
43
48
  import { lookup as dnsLookup, resolveTxt } from "node:dns/promises";
44
49
  import { CrewhausError } from "@crewhaus/errors";
50
+ import { isE164Number, normalizePhoneNumber } from "@crewhaus/tool-permission-matcher";
51
+ import { REDACTED, createSecretRedactor, isEnvName, looksLikePastedSecret, resolveCredentialEnv, secretForms, trimSecretTail, } from "@crewhaus/tool-safety/env";
52
+ import { fetchRaw, readResponseBounded } from "@crewhaus/tool-safety/streams";
45
53
  import { joinTxtChunks, normalizeDomain } from "./lib/dns-records";
46
54
  /** Refusal by the allow-list, the SSRF gate, or a redirect rule. */
47
55
  export class NotifyPermissionError extends CrewhausError {
@@ -61,12 +69,17 @@ export const json = (value) => JSON.stringify(value);
61
69
  export function byString(a, b) {
62
70
  return a < b ? -1 : a > b ? 1 : 0;
63
71
  }
72
+ /** Where an operator allows a credential variable. Every refusal names it. */
73
+ export const SECRET_ENVS_KEY = "tool_config.notify.allowed_secret_envs";
64
74
  const EMPTY_CONFIG = {
65
75
  allowedOrigins: new Set(),
66
76
  allowedRecipients: [],
67
77
  allowedSmtpHosts: new Set(),
68
78
  allowedSenderDomains: new Set(),
69
79
  providers: new Map(),
80
+ allowedSecretEnvs: [],
81
+ allowedSmsRecipients: [],
82
+ allowedPushTargets: [],
70
83
  };
71
84
  let notifyConfig = EMPTY_CONFIG;
72
85
  /** Build a config from a spec-shaped block. Pure; both key spellings accepted. */
@@ -111,8 +124,114 @@ export function buildNotifyConfig(input) {
111
124
  allowedSmtpHosts: smtpHosts,
112
125
  allowedSenderDomains: senderDomains,
113
126
  providers,
127
+ allowedSecretEnvs: buildSecretEnvs(input.allowedSecretEnvs ?? input.allowed_secret_envs),
128
+ allowedSmsRecipients: buildDestinations(input.allowedSmsRecipients ?? input.allowed_sms_recipients, "sms"),
129
+ allowedPushTargets: buildDestinations(input.allowedPushTargets ?? input.allowed_push_targets, "push"),
114
130
  };
115
131
  }
132
+ const DESTINATION_KEYS = {
133
+ sms: "allowed_sms_recipients",
134
+ push: "allowed_push_targets",
135
+ };
136
+ /**
137
+ * A phone number as the SMS allow-list compares it: the separators people
138
+ * write (spaces, `-`, `.`, parentheses) removed wherever they are, so
139
+ * `+1 (555) 000-1111` and `+15550001111` are the same number. It is the
140
+ * permission matcher's own reading, so a deny rule decides on the number
141
+ * this tool checks and sends.
142
+ */
143
+ export const normalizeSmsNumber = normalizePhoneNumber;
144
+ /**
145
+ * Check a destination allow-list at boot. A malformed entry throws, so a
146
+ * misconfiguration surfaces when the harness starts. There is no "allow
147
+ * everything" value: a bare `*` is refused, as an empty allow-list elsewhere
148
+ * here refuses everyone.
149
+ */
150
+ function buildDestinations(raw, kind) {
151
+ const key = `tool_config.notify.${DESTINATION_KEYS[kind]}`;
152
+ if (raw === undefined || raw === null)
153
+ return [];
154
+ if (!Array.isArray(raw))
155
+ throw new NotifyPermissionError(`${key} must be a list`);
156
+ const out = new Set();
157
+ raw.forEach((entry, index) => {
158
+ const where = `${key} entry ${index + 1}`;
159
+ if (typeof entry !== "string")
160
+ throw new NotifyPermissionError(`${where} is not a string`);
161
+ if (kind === "sms") {
162
+ const normalized = normalizeSmsNumber(entry);
163
+ const digits = normalized.endsWith("*") ? normalized.slice(0, -1) : normalized;
164
+ if (!isE164Number(digits)) {
165
+ throw new NotifyPermissionError(`${where} must be a number in E.164 form (a + and up to fifteen digits, such as +15550001111), or a prefix of one ending in * (such as +44*); there is no allow-everything value`);
166
+ }
167
+ out.add(normalized);
168
+ return;
169
+ }
170
+ const target = entry.trim();
171
+ const star = target.indexOf("*");
172
+ if (target === "" || target === "*" || (star !== -1 && star !== target.length - 1)) {
173
+ throw new NotifyPermissionError(`${where} must be a device token or topic, or a prefix of one ending in * (such as topic:ops-*); there is no allow-everything value`);
174
+ }
175
+ out.add(target);
176
+ });
177
+ return [...out].sort(byString);
178
+ }
179
+ /**
180
+ * A provider send's destination as its list compares it, and as it is sent:
181
+ * an SMS number normalised (`+1 (555) 000-1111` is `+15550001111`), a push
182
+ * target trimmed. Sending the call's own spelling would let a gateway read
183
+ * it as another number than the one the list and a permission rule decided on.
184
+ */
185
+ export function destinationFor(to, kind) {
186
+ return kind === "sms" ? normalizeSmsNumber(to) : to.trim();
187
+ }
188
+ /**
189
+ * Why a provider send's destination is refused, or `null` when the list
190
+ * admits it. The one matcher for both lists: an entry matches exactly, or,
191
+ * ending in `*`, as a prefix. An SMS number is compared in normalised E.164
192
+ * form, so a destination that is not a number in that form matches nothing.
193
+ */
194
+ export function destinationRefusal(to, kind, cfg) {
195
+ const key = DESTINATION_KEYS[kind];
196
+ const patterns = kind === "sms" ? cfg.allowedSmsRecipients : cfg.allowedPushTargets;
197
+ const value = destinationFor(to, kind);
198
+ const usable = kind === "push" || isE164Number(value);
199
+ if (usable &&
200
+ patterns.some((p) => (p.endsWith("*") ? value.startsWith(p.slice(0, -1)) : value === p))) {
201
+ return null;
202
+ }
203
+ const what = kind === "sms"
204
+ ? `${JSON.stringify(to)} is not in ${key}${usable ? "" : " (numbers are matched in E.164 form: a + and digits)"}`
205
+ : `that push target is not in ${key}`;
206
+ const how = kind === "sms"
207
+ ? "An operator adds a number in E.164 form, or a prefix such as +44*"
208
+ : "An operator adds a device token or topic, or a prefix ending in *";
209
+ return patterns.length === 0
210
+ ? `${what} (an empty allow-list refuses everyone). ${how}`
211
+ : `${what}. ${how}`;
212
+ }
213
+ /**
214
+ * Check `allowed_secret_envs` at boot: a list of variable NAMES. A
215
+ * malformed entry throws, so a misconfiguration surfaces when the harness
216
+ * starts, and it is never quoted: an operator who pasted a token here, or
217
+ * wrote `$SLACK_BOT_TOKEN` (which the bundle resolves to the token itself),
218
+ * would otherwise see it printed.
219
+ */
220
+ function buildSecretEnvs(raw) {
221
+ if (raw === undefined || raw === null)
222
+ return [];
223
+ if (!Array.isArray(raw)) {
224
+ throw new NotifyPermissionError(`${SECRET_ENVS_KEY} must be a list of variable names`);
225
+ }
226
+ const names = new Set();
227
+ raw.forEach((name, index) => {
228
+ if (!isEnvName(name) || looksLikePastedSecret(name)) {
229
+ throw new NotifyPermissionError(`${SECRET_ENVS_KEY} lists environment variable NAMES (such as SLACK_BOT_TOKEN, written without a $); entry ${index + 1} is not one, and has not been echoed back`);
230
+ }
231
+ names.add(name);
232
+ });
233
+ return [...names].sort(byString);
234
+ }
116
235
  /** Replace the process-global allow-list. Codegen calls this at boot. */
117
236
  export function registerNotifyConfig(input) {
118
237
  notifyConfig = buildNotifyConfig(input);
@@ -296,7 +415,18 @@ const MAX_TXT_RECORD_CHARS = 8192;
296
415
  function withDeadline(work, deadline) {
297
416
  return new Promise((resolve, reject) => {
298
417
  const signal = deadline.signal;
299
- const fail = () => reject(new Error("aborted"));
418
+ // The signal's own reason, so the caller can tell the deadline's timer
419
+ // from a runtime cancel by what it caught rather than by the clock.
420
+ const fail = () => {
421
+ const reason = signal.reason;
422
+ if (reason instanceof Error) {
423
+ reject(reason);
424
+ return;
425
+ }
426
+ const err = new Error("aborted");
427
+ err.name = "AbortError";
428
+ reject(err);
429
+ };
300
430
  if (signal.aborted) {
301
431
  fail();
302
432
  return;
@@ -328,7 +458,10 @@ export async function lookupTxt(name, deadline) {
328
458
  raw = await withDeadline(dnsTxtFn(name), deadline);
329
459
  }
330
460
  catch (err) {
331
- if (deadline.expired()) {
461
+ // An answer that arrived after the deadline's time is still the answer
462
+ // (an NXDOMAIN is still an NXDOMAIN); only the timer's abort is a
463
+ // deadline.
464
+ if (isDeadlineAbort(err, deadline)) {
332
465
  return { outcome: "unknown", reason: `the lookup of "${name}" hit the deadline` };
333
466
  }
334
467
  const code = err.code;
@@ -665,6 +798,19 @@ export function expandIpv6(raw) {
665
798
  const groups = parseIpv6(raw);
666
799
  return groups === null ? null : [...groups];
667
800
  }
801
+ // ---------------------------------------------------------------------------
802
+ // deadlines
803
+ // ---------------------------------------------------------------------------
804
+ /**
805
+ * The reason a deadline's own timer aborts its signal with. An error is
806
+ * traced to the deadline by this reason, never by the clock: a transport
807
+ * failure that merely ARRIVES after the deadline's time (a starved event
808
+ * loop delivers it before the timer callback runs) is that failure, and a
809
+ * runtime cancel aborts with the runtime's reason instead.
810
+ */
811
+ export class DeadlineElapsedError extends Error {
812
+ name = "TimeoutError";
813
+ }
668
814
  /**
669
815
  * A deadline that also honours the runtime's own cancellation. Every
670
816
  * outbound tool here opens one before its first byte and cancels it in a
@@ -673,7 +819,7 @@ export function expandIpv6(raw) {
673
819
  export function startDeadline(ms, outer) {
674
820
  const ctrl = new AbortController();
675
821
  const startedAt = Date.now();
676
- const timer = setTimeout(() => ctrl.abort(new Error(`deadline of ${ms}ms elapsed`)), ms);
822
+ const timer = setTimeout(() => ctrl.abort(new DeadlineElapsedError(`deadline of ${ms}ms elapsed`)), ms);
677
823
  const onOuter = () => ctrl.abort(outer?.reason);
678
824
  if (outer !== undefined) {
679
825
  if (outer.aborted)
@@ -683,8 +829,9 @@ export function startDeadline(ms, outer) {
683
829
  }
684
830
  return {
685
831
  signal: ctrl.signal,
686
- remaining: () => Math.max(0, ms - (Date.now() - startedAt)),
832
+ remaining: () => (ctrl.signal.aborted ? 0 : Math.max(0, ms - (Date.now() - startedAt))),
687
833
  expired: () => Date.now() - startedAt >= ms,
834
+ timedOut: () => ctrl.signal.aborted && ctrl.signal.reason instanceof DeadlineElapsedError,
688
835
  cancel: () => {
689
836
  clearTimeout(timer);
690
837
  outer?.removeEventListener("abort", onOuter);
@@ -709,60 +856,30 @@ export function sleep(ms, signal) {
709
856
  signal?.addEventListener("abort", onAbort, { once: true });
710
857
  });
711
858
  }
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
859
  /**
737
- * Read a secret out of the named environment variable.
860
+ * Read a secret out of the named environment variable, if and only if
861
+ * `allowed` lists it.
738
862
  *
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.
863
+ * The NAME travels through the tool call; the value never does. `allowed`
864
+ * is the operator's `allowed_secret_envs` for a name a call supplies, or a
865
+ * provider's own `auth.envVar` for that provider's call: a call may choose
866
+ * among the listed names and never add one, so it cannot send another
867
+ * process secret (the LLM provider's key, say) to an allowed origin, where
868
+ * the egress classifier would see only the name. The refusal names the key
869
+ * to set, reads the same whether or not an unlisted variable is set, and
870
+ * never quotes a value or a "name" that is really a pasted secret, because a
871
+ * tool result is a transcript, a trace and usually an eval report.
744
872
  */
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 };
873
+ export function resolveSecret(envVar, what, allowed, env = process.env) {
874
+ const resolved = resolveCredentialEnv(envVar, {
875
+ allowed,
876
+ purpose: what,
877
+ configKey: SECRET_ENVS_KEY,
878
+ env,
879
+ });
880
+ return resolved.ok
881
+ ? { ok: true, value: resolved.value }
882
+ : { ok: false, message: resolved.reason };
766
883
  }
767
884
  /** Header names that must never be supplied inline, and never survive a hop. */
768
885
  export const CREDENTIAL_HEADERS = new Set([
@@ -789,10 +906,10 @@ export function rejectInlineCredentials(headers) {
789
906
  * guarded exactly as hard as one that puts it in `Authorization` — and the
790
907
  * resolved secret values, which feed the redactor.
791
908
  */
792
- export function applyAuth(headers, auth, env = process.env) {
909
+ export function applyAuth(headers, auth, allowed, env = process.env) {
793
910
  if (auth === undefined)
794
911
  return { ok: true, secretHeaders: new Set(), secrets: [] };
795
- const resolved = resolveSecret(auth.envVar, "auth profile envVar", env);
912
+ const resolved = resolveSecret(auth.envVar, "the auth profile", allowed, env);
796
913
  if (!resolved.ok)
797
914
  return { ok: false, message: resolved.message };
798
915
  const secret = resolved.value;
@@ -807,9 +924,16 @@ export function applyAuth(headers, auth, env = process.env) {
807
924
  message: 'auth type "basic" needs a username; the password comes from envVar',
808
925
  };
809
926
  }
810
- const encoded = Buffer.from(`${auth.username}:${secret}`, "utf8").toString("base64");
927
+ const publicPrefix = `${auth.username}:`;
928
+ const encoded = Buffer.from(`${publicPrefix}${secret}`, "utf8").toString("base64");
811
929
  headers["Authorization"] = `Basic ${encoded}`;
812
- return { ok: true, secretHeaders: new Set(["authorization"]), secrets: [secret, encoded] };
930
+ // The pair's `user:` is the account name, not a secret: a reply that
931
+ // ends with it is left alone, while every spelling of the pair is not.
932
+ return {
933
+ ok: true,
934
+ secretHeaders: new Set(["authorization"]),
935
+ secrets: [secret, { publicPrefix, secret }],
936
+ };
813
937
  }
814
938
  if (auth.headerName === undefined) {
815
939
  return { ok: false, message: 'auth type "header" needs a headerName' };
@@ -835,31 +959,42 @@ export function applyAuth(headers, auth, env = process.env) {
835
959
  *
836
960
  * Secrets shorter than six characters are left alone: replacing every "x" in
837
961
  * a result would mangle it without protecting anything real.
962
+ *
963
+ * This is tool-safety's redactor: every spelling of a secret, and the part
964
+ * of one a cut left at a string's end. A cut this package makes itself is
965
+ * trimmed where it is made ({@link readCapped}, {@link excerptOf}), because a
966
+ * cut inside a JSON result is not at the end of the text this runs on (net
967
+ * attacker review: a byte cap through an echoed header returned all but one
968
+ * character of the credential).
969
+ *
970
+ * `identifiers` (an SMTP username) are replaced only whole, as 0.7.0 did:
971
+ * they are not secrets, so the start of one at a string's end is not cut.
838
972
  */
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;
973
+ export function redactorFor(secrets, identifiers = []) {
974
+ const redactSecrets = createSecretRedactor(secrets);
975
+ const idForms = [
976
+ ...new Set(identifiers.flatMap((v) => v === undefined || v.trim().length < 6 ? [] : secretForms(v).filter((f) => f.length >= 6))),
977
+ ].sort((a, b) => b.length - a.length);
856
978
  return (text) => {
857
- let out = text;
858
- for (const form of ordered)
859
- out = out.split(form).join("<redacted>");
979
+ let out = redactSecrets(text);
980
+ for (const form of idForms)
981
+ if (out.includes(form))
982
+ out = out.split(form).join(REDACTED);
860
983
  return out;
861
984
  };
862
985
  }
986
+ /** The most of a reply an error message quotes. */
987
+ export const EXCERPT_CHARS = 500;
988
+ /**
989
+ * The first {@link EXCERPT_CHARS} of a reply, for an error message. When
990
+ * that cuts the reply, whatever part of an echoed credential the cut leaves
991
+ * at the end is trimmed, since the redactor matches whole spellings.
992
+ */
993
+ export function excerptOf(text, secrets) {
994
+ if (text.length <= EXCERPT_CHARS)
995
+ return text;
996
+ return trimSecretTail(text.slice(0, EXCERPT_CHARS), secrets);
997
+ }
863
998
  /** Response headers as a sorted plain object, credentials removed. */
864
999
  const STRIPPED_RESPONSE_HEADERS = new Set([
865
1000
  "authorization",
@@ -879,13 +1014,19 @@ export function responseHeaders(res) {
879
1014
  /**
880
1015
  * Dial the vetted IP while keeping the real hostname for the `Host` header
881
1016
  * and TLS SNI, so virtual hosting and certificate validation still work.
1017
+ *
1018
+ * Both branches fetch with the body kept RAW (`fetchRaw`, Bun's
1019
+ * `decompress: false`). Without it Bun inflates a gzip, deflate, br or zstd
1020
+ * reply in native code before any reader sees a byte, so a 300 KB gzip reply
1021
+ * cost about 860 MB however small `maxBytes` was (security-5#7).
1022
+ * {@link readCapped} decodes the body itself, under the cap.
882
1023
  */
883
1024
  function pinnedFetch(req, pinnedIp) {
884
1025
  const original = new URL(req.url);
885
1026
  const host = original.hostname;
886
1027
  const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
887
1028
  if (unbracketed === pinnedIp || pinnedIp === "")
888
- return globalThis.fetch(req);
1029
+ return fetchRaw(req);
889
1030
  const pinnedUrl = new URL(original.toString());
890
1031
  pinnedUrl.hostname = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp;
891
1032
  const headers = new Headers(req.headers);
@@ -901,7 +1042,7 @@ function pinnedFetch(req, pinnedIp) {
901
1042
  init.body = req.body;
902
1043
  init.duplex = "half";
903
1044
  }
904
- return globalThis.fetch(pinnedUrl.toString(), init);
1045
+ return fetchRaw(pinnedUrl.toString(), init);
905
1046
  }
906
1047
  let rawFetch = pinnedFetch;
907
1048
  export function _setRawFetch(fn) {
@@ -921,6 +1062,13 @@ export async function openRequest(o) {
921
1062
  const lower = name.toLowerCase();
922
1063
  return CREDENTIAL_HEADERS.has(lower) || o.credentialHeaders?.has(lower) === true;
923
1064
  };
1065
+ // Ask for the body as it is. The reader decodes gzip, deflate, br and zstd
1066
+ // under the cap anyway, so a provider that compresses regardless still
1067
+ // works; asking for identity just spares the decoding. A caller that set
1068
+ // its own Accept-Encoding keeps it.
1069
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "accept-encoding")) {
1070
+ headers["accept-encoding"] = "identity";
1071
+ }
924
1072
  let redirects = 0;
925
1073
  let credentialsDropped = false;
926
1074
  let current = o.url;
@@ -993,57 +1141,45 @@ async function discard(res) {
993
1141
  }
994
1142
  }
995
1143
  /**
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.
1144
+ * Drain a body with a hard byte cap on its DECODED size, so a hostile
1145
+ * endpoint cannot pin memory: the body arrives raw (see `pinnedFetch`), a
1146
+ * gzip, deflate, br or zstd body is decoded here in small steps, and the
1147
+ * decoder stops once `maxBytes` exist. The cap bounds what is ever held, not
1148
+ * what is kept after buffering.
1149
+ *
1150
+ * A body this reader cannot decode within the bound (an unknown or stacked
1151
+ * coding, a corrupt stream) is a refusal that quotes nothing the endpoint
1152
+ * sent. An aborted read throws an `AbortError`, so `describeFailure` reports
1153
+ * the deadline or the abort as it does for the request itself.
999
1154
  */
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
- }
1155
+ export async function readCapped(res, maxBytes, signal, secrets = []) {
1156
+ const read = await readResponseBounded(res, {
1157
+ maxBytes,
1158
+ ...(signal !== undefined ? { signal } : {}),
1159
+ });
1160
+ if (read.ok) {
1161
+ return {
1162
+ // A cut can fall inside a credential the endpoint echoed; what it
1163
+ // leaves is a prefix no whole spelling matches, so it goes here.
1164
+ text: read.truncated ? trimSecretTail(read.text, secrets) : read.text,
1165
+ bytes: read.bytes.byteLength,
1166
+ truncated: read.truncated,
1167
+ };
1027
1168
  }
1028
- finally {
1029
- try {
1030
- reader.releaseLock();
1169
+ switch (read.code) {
1170
+ case "aborted": {
1171
+ const err = new Error("the read was aborted before the reply ended");
1172
+ err.name = "AbortError";
1173
+ throw err;
1031
1174
  }
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;
1175
+ case "unsupported-encoding":
1176
+ throw new NotifyPermissionError("the endpoint sent its reply in a stack of content-encodings this tool cannot decode within its byte cap, so the reply was not read");
1177
+ case "decode-error":
1178
+ case "auto-decompressed":
1179
+ throw new NotifyPermissionError("the endpoint's reply is labelled as compressed but could not be decoded, so it was not read");
1180
+ default:
1181
+ throw new NotifyPermissionError("the endpoint's reply could not be read to the end");
1041
1182
  }
1042
- return {
1043
- text: new TextDecoder("utf-8", { fatal: false }).decode(merged),
1044
- bytes: total,
1045
- truncated,
1046
- };
1047
1183
  }
1048
1184
  /** A URL the caller supplied, or a readable refusal that does not echo it. */
1049
1185
  export function parseUrl(raw) {
@@ -1054,6 +1190,42 @@ export function parseUrl(raw) {
1054
1190
  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
1191
  }
1056
1192
  }
1193
+ /**
1194
+ * True for a value made only of dots. `.` and `..` are the two values
1195
+ * `encodeURIComponent` does NOT neutralise: they encode to themselves, and
1196
+ * the URL parser then RESOLVES them, so a message id of `..` substituted
1197
+ * into `/channels/1/messages/{id}` addresses `/channels/1/` — the channel,
1198
+ * not a message. A dot segment is never an id.
1199
+ */
1200
+ export function isDotSegment(value) {
1201
+ return /^\.+$/.test(value);
1202
+ }
1203
+ /**
1204
+ * `template` with every `placeholder` replaced by `value`, percent-encoded,
1205
+ * as the URL that will be requested — or why not: `invalid` when the
1206
+ * template is not an absolute URL, `reshaped` when the parser would send
1207
+ * the request anywhere but the template's own path with the value written
1208
+ * in.
1209
+ *
1210
+ * The check is made on the URL the parser produced, not on the string it
1211
+ * was built from: the template is also parsed with a sentinel in the
1212
+ * value's place, and the two paths must match once the sentinel is the
1213
+ * value. Whatever normalisation the parser applies (a dot segment today),
1214
+ * a value that changes the shape of the path is refused.
1215
+ */
1216
+ export function substituteIntoUrl(template, placeholder, value) {
1217
+ const encoded = encodeURIComponent(value);
1218
+ const parsed = parseUrl(template.split(placeholder).join(encoded));
1219
+ const shape = parseUrl(template.split(placeholder).join(PATH_SENTINEL));
1220
+ if (typeof parsed === "string" || typeof shape === "string")
1221
+ return { ok: false, why: "invalid" };
1222
+ if (parsed.pathname !== shape.pathname.split(PATH_SENTINEL).join(encoded)) {
1223
+ return { ok: false, why: "reshaped" };
1224
+ }
1225
+ return { ok: true, url: parsed };
1226
+ }
1227
+ /** A path segment no parser rewrites and no real value contains. */
1228
+ const PATH_SENTINEL = "crewhaus-substitution-4b1f0c";
1057
1229
  /** Read a dotted path out of a parsed JSON body. `undefined` when absent. */
1058
1230
  export function readPath(value, path) {
1059
1231
  let current = value;
@@ -1081,15 +1253,71 @@ export function readPath(value, path) {
1081
1253
  export function describeFailure(err, deadline) {
1082
1254
  if (err instanceof NotifyPermissionError)
1083
1255
  return err.message;
1084
- if (deadline?.expired() === true)
1256
+ // The cause decides, not the clock: see DeadlineElapsedError.
1257
+ if (isDeadlineAbort(err, deadline))
1085
1258
  return "deadline elapsed before the send completed";
1259
+ const aborted = "the send was aborted before it completed";
1260
+ if (deadline?.signal.aborted === true && err === deadline.signal.reason)
1261
+ return aborted;
1086
1262
  if (err instanceof Error) {
1087
1263
  if (err.name === "AbortError")
1088
- return "the send was aborted before it completed";
1089
- return `${err.name}: ${err.message}`;
1264
+ return aborted;
1265
+ // A real failure that arrived late is still that failure; the clock is
1266
+ // mentioned, because the caller may want a longer deadline as well.
1267
+ const late = deadline?.expired() === true ? " (the deadline had also elapsed)" : "";
1268
+ return `${err.name}: ${err.message}${late}`;
1090
1269
  }
1091
1270
  return String(err);
1092
1271
  }
1272
+ /**
1273
+ * Whether `err` is what the deadline's own timer did: its abort reason, or an
1274
+ * abort-shaped error (a body reader's AbortError) raised after that timer
1275
+ * fired. Exported for the few callers that report a deadline themselves.
1276
+ */
1277
+ export function isDeadlineAbort(err, deadline) {
1278
+ if (err instanceof DeadlineElapsedError)
1279
+ return true;
1280
+ if (deadline?.timedOut() !== true)
1281
+ return false;
1282
+ if (err === deadline.signal.reason)
1283
+ return true;
1284
+ return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
1285
+ }
1286
+ const DELIVERY_KNOBS = new Set([
1287
+ "idempotencyKey",
1288
+ "idempotencyHeader",
1289
+ "timeoutMs",
1290
+ "maxBytes",
1291
+ "retries",
1292
+ "backoffMs",
1293
+ "justification",
1294
+ ]);
1295
+ /** Object keys sorted, so `{a, b}` and `{b, a}` are one request. */
1296
+ function sortedKeys(_key, value) {
1297
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1298
+ return value;
1299
+ const sorted = Object.create(null);
1300
+ for (const key of Object.keys(value).sort()) {
1301
+ sorted[key] = value[key];
1302
+ }
1303
+ return sorted;
1304
+ }
1305
+ export function requestFingerprint(args) {
1306
+ const fields = new Map();
1307
+ for (const key of Object.keys(args).sort()) {
1308
+ const value = args[key];
1309
+ if (DELIVERY_KNOBS.has(key) || value === undefined)
1310
+ continue;
1311
+ const canonical = JSON.stringify(value, sortedKeys) ?? "undefined";
1312
+ fields.set(key, createHash("sha256").update(canonical, "utf8").digest("hex"));
1313
+ }
1314
+ return fields;
1315
+ }
1316
+ /** The fields two fingerprints disagree on, sorted; empty when they match. */
1317
+ function differingFields(a, b) {
1318
+ const names = new Set([...a.keys(), ...b.keys()]);
1319
+ return [...names].filter((name) => a.get(name) !== b.get(name)).sort(byString);
1320
+ }
1093
1321
  /**
1094
1322
  * The per-process record of what has already been sent under which key.
1095
1323
  *
@@ -1097,8 +1325,14 @@ export function describeFailure(err, deadline) {
1097
1325
  * agent, and "post it again" is the wrong answer when the first post
1098
1326
  * succeeded. Every sending tool takes an `idempotencyKey`; the key is passed
1099
1327
  * 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.
1328
+ * second call with the same key and the same request returns the first
1329
+ * call's result and posts nothing.
1330
+ *
1331
+ * A key names ONE request. The same key with a different destination or
1332
+ * body used to be answered from here too, so it returned the first call's
1333
+ * `sent: true` and message id for a message that was never sent. It is now
1334
+ * a `conflict`, which the tool refuses (as a provider that honours
1335
+ * Idempotency-Key would), and nothing goes out.
1102
1336
  *
1103
1337
  * This is deliberate hidden state, and it is the one place in the package
1104
1338
  * where a repeated call does not repeat its effect — which is the entire
@@ -1108,12 +1342,24 @@ export function describeFailure(err, deadline) {
1108
1342
  */
1109
1343
  const LEDGER_LIMIT = 512;
1110
1344
  const ledger = new Map();
1111
- export function ledgerLookup(tool, key) {
1345
+ /**
1346
+ * `ignoring` names fields left out of the comparison: EmailSend asks with
1347
+ * `attachmentContent` ignored when an attachment can no longer be read, so
1348
+ * a retry after a temp file was cleaned up finds the message it already
1349
+ * sent instead of reporting it unsent.
1350
+ */
1351
+ export function ledgerLookup(tool, key, fingerprint, ignoring = []) {
1112
1352
  if (key === undefined || key === "")
1113
1353
  return undefined;
1114
- return ledger.get(`${tool}\u0000${key}`);
1354
+ const entry = ledger.get(`${tool}\u0000${key}`);
1355
+ if (entry === undefined)
1356
+ return undefined;
1357
+ const differs = differingFields(entry.fingerprint, fingerprint).filter((name) => !ignoring.includes(name));
1358
+ return differs.length === 0
1359
+ ? { kind: "replay", result: entry.result }
1360
+ : { kind: "conflict", differs };
1115
1361
  }
1116
- export function ledgerRecord(tool, key, result) {
1362
+ export function ledgerRecord(tool, key, fingerprint, result) {
1117
1363
  if (key === undefined || key === "")
1118
1364
  return;
1119
1365
  const id = `${tool}\u0000${key}`;
@@ -1123,7 +1369,7 @@ export function ledgerRecord(tool, key, result) {
1123
1369
  if (!oldest.done)
1124
1370
  ledger.delete(oldest.value);
1125
1371
  }
1126
- ledger.set(id, { tool, result });
1372
+ ledger.set(id, { tool, fingerprint, result });
1127
1373
  }
1128
1374
  /** Test-only — a fresh process's empty ledger. */
1129
1375
  export function _resetIdempotencyLedger() {