@crewhaus/tool-notify 0.7.0 → 0.8.1

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/index.js CHANGED
@@ -19,12 +19,15 @@
19
19
  * exactly this, and no tool here opts out of it.
20
20
  * 2. **The gate is tool-http's gate.** Allow-list, SSRF refusal, IP
21
21
  * pinning, credential stripping and byte caps all live in `./net` and
22
- * every outbound byte passes through them. Recipients and SMTP hosts
23
- * have their own allow-lists, equally fail-closed. See that file's
24
- * header.
25
- * 3. **Credentials are environment variable NAMES.** A Slack or Discord
26
- * incoming-webhook URL is itself a credential, so it is named rather
27
- * than passed, and no refusal ever prints a webhook path.
22
+ * every outbound byte passes through them. Email recipients, SMTP
23
+ * hosts, SMS numbers and push targets have their own allow-lists,
24
+ * equally fail-closed. See that file's header.
25
+ * 3. **Credentials are environment variable NAMES**, and only names the
26
+ * operator listed in `allowed_secret_envs` (or a provider's own
27
+ * `auth.envVar`, for that provider): a call chooses among them and can
28
+ * never add one. A Slack or Discord incoming-webhook URL is itself a
29
+ * credential, so it is named rather than passed, and no refusal ever
30
+ * prints a webhook path.
28
31
  * 4. **Determinism, and no clock.** Listings sort, comparisons are
29
32
  * locale-free, nothing is random — MIME boundaries are derived from the
30
33
  * message's own content — and anything that needs "now" takes it as an
@@ -34,9 +37,11 @@
34
37
  * The one deliberate exception to "same input, same output" is the
35
38
  * idempotency ledger in `./net`: a second call carrying an
36
39
  * `idempotencyKey` that already succeeded returns the first result and posts
37
- * nothing. That is the point of an idempotency key, and it is the only
38
- * hidden state in the package.
40
+ * nothing — when it is the same request; the same key with a different
41
+ * destination or content is refused. That is the point of an idempotency
42
+ * key, and it is the only hidden state in the package.
39
43
  */
44
+ import { createHash } from "node:crypto";
40
45
  import { readFileSync, statSync } from "node:fs";
41
46
  import * as path from "node:path";
42
47
  import { buildTool } from "@crewhaus/tool-builder";
@@ -44,13 +49,15 @@ import { z } from "zod";
44
49
  import { EDITABLE_PLATFORMS, THREADED_PLATFORMS, buildChatPayload, clampToPlatform, renderBlocks, } from "./lib/blocks";
45
50
  import { buildDigest, digestToBlocks, digestToText } from "./lib/digest";
46
51
  import { normalizeDomain, normalizeSelector, parseDkim, parseDmarc, parseSpf, } from "./lib/dns-records";
52
+ import { parseOffsetInstant } from "./lib/instant";
47
53
  import { composeMessage, isValidAddress } from "./lib/mime";
48
54
  import { draftChecks, verdictFrom } from "./lib/preflight";
49
55
  import { quietDecision } from "./lib/quiet";
50
56
  import { rateLimitGate as evaluateRateLimit } from "./lib/ratelimit";
51
57
  import { formatSignatureHeader, hmacHex, signedPayload } from "./lib/sign";
52
58
  import { renderTemplate as renderTemplateValues } from "./lib/template";
53
- import { DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_MS, MAX_MAX_BYTES, MAX_TIMEOUT_MS, NotifyPermissionError, applyAuth, assertNotSsrf, assertOriginAllowed, assertSenderDomainAllowed, assertSmtpHostAllowed, byString, describeFailure, json, ledgerLookup, ledgerRecord, lookupTxt, openRequest, parseUrl, readCapped, readPath, recipientAllowed, redactorFor, rejectInlineCredentials, resolveNotifyConfig, resolveSecret, safeUrlLabel, sleep, startDeadline, } from "./net";
59
+ import { isTeamsWebhookPath } from "./lib/webhook-path";
60
+ import { DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_MS, MAX_MAX_BYTES, MAX_TIMEOUT_MS, NotifyPermissionError, applyAuth, assertNotSsrf, assertOriginAllowed, assertSenderDomainAllowed, assertSmtpHostAllowed, byString, describeFailure, destinationFor, destinationRefusal, excerptOf, isDotSegment, json, ledgerLookup, ledgerRecord, lookupTxt, openRequest, parseUrl, readCapped, readPath, recipientAllowed, redactorFor, rejectInlineCredentials, requestFingerprint, resolveNotifyConfig, resolveSecret, safeUrlLabel, sleep, startDeadline, substituteIntoUrl, } from "./net";
54
61
  import { resolveSafe } from "./paths";
55
62
  import { sendMail } from "./smtp";
56
63
  export { NotifyPermissionError, _resetIdempotencyLedger, _resetNotifyConfig, _setDnsLookup, _setDnsTxtResolver, _setRawFetch, __setPrivateHostsAllowedForTest, buildNotifyConfig, canonicalizeOrigin, getNotifyConfig, registerNotifyConfig, } from "./net";
@@ -82,7 +89,7 @@ const envVarSchema = (what) => z
82
89
  .string()
83
90
  .min(1)
84
91
  .max(64)
85
- .describe(`NAME of the environment variable holding ${what} — never the value itself`);
92
+ .describe(`NAME of the environment variable holding ${what} — never the value itself; it must be listed in tool_config.notify.allowed_secret_envs`);
86
93
  const timeoutSchema = z
87
94
  .number()
88
95
  .int()
@@ -102,7 +109,7 @@ const idempotencySchema = z
102
109
  .min(1)
103
110
  .max(200)
104
111
  .optional()
105
- .describe("a key that makes a retry safe: the first call under this key is sent and recorded for the life of the process, and a later call with the same key returns that result without sending again. It is also passed to the provider where the provider honours one");
112
+ .describe("a key that makes a retry safe: the first call under this key is sent and recorded for the life of the process, and a later call with the same key and the same request returns that result without sending again. The same key with a different destination or content is refused and sends nothing — a key names one message. It is also passed to the provider where the provider honours one");
106
113
  const authSchema = z
107
114
  .object({
108
115
  type: z
@@ -132,15 +139,27 @@ function withRedaction(secrets, timeoutMs, ctx) {
132
139
  cfg: resolveNotifyConfig(ctx?.toolConfig),
133
140
  deadline: startDeadline(timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal),
134
141
  redact: redactorFor(secrets),
142
+ secrets,
135
143
  };
136
144
  }
137
- /** Reject inline credentials, then attach the auth profile's secret. */
138
- function prepareHeaders(raw, auth) {
145
+ /**
146
+ * The variable a provider's operator-written auth profile names: allowed for
147
+ * that provider's own calls without being listed in allowed_secret_envs, and
148
+ * for nothing else.
149
+ */
150
+ function providerAuthEnvs(profile) {
151
+ return profile.auth === undefined ? [] : [profile.auth.envVar];
152
+ }
153
+ /**
154
+ * Reject inline credentials, then attach the auth profile's secret, read
155
+ * only from a variable `allowed` (the operator's allowed_secret_envs) lists.
156
+ */
157
+ function prepareHeaders(raw, auth, allowed) {
139
158
  const headers = { ...(raw ?? {}) };
140
159
  const inline = rejectInlineCredentials(headers);
141
160
  if (inline !== null)
142
161
  return { ok: false, message: inline };
143
- const applied = applyAuth(headers, auth);
162
+ const applied = applyAuth(headers, auth, allowed);
144
163
  if (!applied.ok)
145
164
  return { ok: false, message: applied.message };
146
165
  return {
@@ -150,7 +169,20 @@ function prepareHeaders(raw, auth) {
150
169
  secrets: [...applied.secrets],
151
170
  };
152
171
  }
153
- /** POST (or whatever verb) to an allow-listed URL and read a capped reply. */
172
+ /** The `replyUnreadable` field for a result, when there is one. */
173
+ function unreadableField(outcome) {
174
+ return outcome.replyUnreadable !== undefined ? { replyUnreadable: outcome.replyUnreadable } : {};
175
+ }
176
+ /**
177
+ * POST (or whatever verb) to an allow-listed URL and read a capped reply.
178
+ *
179
+ * Once `openRequest` returns, the request has been delivered and answered.
180
+ * A failure to READ the reply (a coding this reader will not decode, a
181
+ * corrupt body, the deadline while it streams) does not un-send it, so it is
182
+ * returned as `replyUnreadable` rather than thrown: 0.7.1's first cut threw
183
+ * it, the tools reported "nothing was sent" for a delivered message, and a
184
+ * retry under the same idempotency key delivered it again (net review).
185
+ */
154
186
  async function send(url, method, headers, body, prepared, maxBytes, secretHeaders) {
155
187
  const opened = await openRequest({
156
188
  url,
@@ -162,7 +194,22 @@ async function send(url, method, headers, body, prepared, maxBytes, secretHeader
162
194
  redirect: "refuse",
163
195
  credentialHeaders: secretHeaders,
164
196
  });
165
- const capped = await readCapped(opened.res, maxBytes);
197
+ const status = opened.res.status;
198
+ let capped;
199
+ try {
200
+ capped = await readCapped(opened.res, maxBytes, prepared.deadline.signal, prepared.secrets);
201
+ }
202
+ catch (err) {
203
+ return {
204
+ status,
205
+ ok: status >= 200 && status < 300,
206
+ body: "",
207
+ excerpt: "",
208
+ truncated: false,
209
+ parsed: undefined,
210
+ replyUnreadable: describeFailure(err, prepared.deadline),
211
+ };
212
+ }
166
213
  let parsed;
167
214
  try {
168
215
  parsed = JSON.parse(capped.text);
@@ -174,6 +221,7 @@ async function send(url, method, headers, body, prepared, maxBytes, secretHeader
174
221
  status: opened.res.status,
175
222
  ok: opened.res.status >= 200 && opened.res.status < 300,
176
223
  body: capped.text,
224
+ excerpt: excerptOf(capped.text, prepared.secrets),
177
225
  truncated: capped.truncated,
178
226
  parsed,
179
227
  };
@@ -185,7 +233,9 @@ async function send(url, method, headers, body, prepared, maxBytes, secretHeader
185
233
  */
186
234
  function platformFailure(platform, outcome) {
187
235
  if (!outcome.ok) {
188
- return `the service answered ${outcome.status}: ${outcome.body.slice(0, 500)}`;
236
+ return outcome.replyUnreadable !== undefined
237
+ ? `the service answered ${outcome.status}, and its reply could not be read: ${outcome.replyUnreadable}`
238
+ : `the service answered ${outcome.status}: ${outcome.excerpt}`;
189
239
  }
190
240
  if (platform === "slack" && typeof outcome.parsed === "object" && outcome.parsed !== null) {
191
241
  const record = outcome.parsed;
@@ -195,6 +245,31 @@ function platformFailure(platform, outcome) {
195
245
  }
196
246
  return null;
197
247
  }
248
+ /**
249
+ * Slack's Web API keeps its verdict in the reply body (`ok:false` on a
250
+ * 200). When that verdict cannot be read, the request was delivered but
251
+ * whether Slack took it is unknown: said so, never "sent" and never "not
252
+ * sent". It cannot be read when the body could not be read at all, or, in
253
+ * API mode, when the body is not a JSON object: most often because it was
254
+ * longer than maxBytes and was cut (net attacker review: a 680-byte
255
+ * `{"ok":false,"error":"invalid_blocks",...}` cut at 256 bytes was reported
256
+ * `sent: true`, and ledgered). An incoming webhook answers with its status
257
+ * and a plain `ok`, and other platforms answer with the status, which is
258
+ * known.
259
+ */
260
+ function unknownVerdict(platform, outcome, mode) {
261
+ if (platform !== "slack" || !outcome.ok)
262
+ return null;
263
+ if (outcome.replyUnreadable !== undefined) {
264
+ return `Slack answered ${outcome.status}, but its reply, which says whether it accepted the request, could not be read (${outcome.replyUnreadable}); check before trying again`;
265
+ }
266
+ if (mode !== "api" || (typeof outcome.parsed === "object" && outcome.parsed !== null)) {
267
+ return null;
268
+ }
269
+ return outcome.truncated
270
+ ? `Slack answered ${outcome.status}, but its reply, which says whether it accepted the request, was longer than maxBytes and was cut before it could be read; check before trying again, and raise maxBytes`
271
+ : `Slack answered ${outcome.status}, but its reply is not the JSON object that says whether it accepted the request; check before trying again`;
272
+ }
198
273
  /** Where a platform keeps the id of the message just posted. */
199
274
  function postedMessageId(platform, outcome) {
200
275
  if (typeof outcome.parsed !== "object" || outcome.parsed === null)
@@ -203,9 +278,9 @@ function postedMessageId(platform, outcome) {
203
278
  const raw = platform === "slack" ? record["ts"] : record["id"];
204
279
  return typeof raw === "string" ? raw : undefined;
205
280
  }
206
- /** Resolve a webhook URL held in an environment variable. */
207
- function webhookUrlFrom(envVar) {
208
- const resolved = resolveSecret(envVar, "the webhook URL");
281
+ /** Resolve a webhook URL held in an environment variable `allowed` lists. */
282
+ function webhookUrlFrom(envVar, allowed) {
283
+ const resolved = resolveSecret(envVar, "the webhook URL", allowed);
209
284
  if (!resolved.ok)
210
285
  return resolved.message;
211
286
  const parsed = parseUrl(resolved.value);
@@ -217,24 +292,41 @@ function webhookUrlFrom(envVar) {
217
292
  /**
218
293
  * The incoming-webhook URL shapes that ARE the credential.
219
294
  *
220
- * A Slack `/services/T…/B…/xxxx` or Discord `/api/webhooks/<id>/<token>`
221
- * path grants posting rights to whoever holds it. Passed inline it lands in
295
+ * A Slack `/services/T…/B…/xxxx`, Discord `/api/webhooks/<id>/<token>` or
296
+ * Teams `/webhookb2/…@…/IncomingWebhook/…` path grants posting rights to
297
+ * whoever holds it. Passed inline it lands in
222
298
  * the transcript, the trace and usually an eval report — the same reason
223
299
  * every other secret here is named rather than passed — so it is refused
224
300
  * with the variable form pointed at instead.
225
301
  */
302
+ const SLACK_WEBHOOK_PATH = /^\/services\/T[^/]+\/B[^/]+\/.+/i;
303
+ const DISCORD_WEBHOOK_PATH = /^\/api\/webhooks\/\d+\/.+/i;
226
304
  const WEBHOOK_CREDENTIAL_PATHS = [
227
- /^\/services\/T[^/]+\/B[^/]+\/.+/i,
228
- /^\/api\/webhooks\/\d+\/.+/i,
229
- /^\/webhookb2\/[^/]+@[^/]+\/IncomingWebhook\/.+/i,
305
+ (path) => SLACK_WEBHOOK_PATH.test(path),
306
+ (path) => DISCORD_WEBHOOK_PATH.test(path),
307
+ // A Teams path is read a segment at a time: see isTeamsWebhookPath.
308
+ isTeamsWebhookPath,
230
309
  ];
231
310
  /** A refusal when `url` carries a credential in its path, else `null`. */
232
311
  function inlineWebhookCredential(url) {
233
- if (!WEBHOOK_CREDENTIAL_PATHS.some((shape) => shape.test(url.pathname)))
312
+ if (!WEBHOOK_CREDENTIAL_PATHS.some((shape) => shape(url.pathname)))
234
313
  return null;
235
314
  return "that URL is an incoming-webhook URL, whose path is itself the credential — name the environment variable holding it in urlEnv instead of passing it here, where it would be recorded verbatim. (The value has not been echoed back.)";
236
315
  }
237
316
  const notSentBecause = (reason) => `nothing was sent: ${reason}`;
317
+ /**
318
+ * What the idempotency ledger already says about this call, or undefined
319
+ * when it should go ahead: the first call's result for a retry of the same
320
+ * request, and a refusal for a different request under a key already used.
321
+ */
322
+ function ledgerAnswer(tool, key, fingerprint) {
323
+ const answer = ledgerLookup(tool, key, fingerprint);
324
+ if (answer === undefined)
325
+ return undefined;
326
+ if (answer.kind === "replay")
327
+ return answer.result;
328
+ return notSentBecause(`idempotencyKey "${key}" already sent a different ${tool} request in this process (it differed in ${answer.differs.join(", ")}). A key names one message — use a new key for a new message`);
329
+ }
238
330
  // ---------------------------------------------------------------------------
239
331
  // chat
240
332
  // ---------------------------------------------------------------------------
@@ -245,7 +337,7 @@ const chatTargetShape = {
245
337
  .min(1)
246
338
  .max(64)
247
339
  .optional()
248
- .describe("NAME of the environment variable holding the incoming-webhook URL. The URL is the credential — a Slack /services/… or Discord /api/webhooks/… path grants posting rights to anyone holding it — so it is named, never passed, and never printed in an error"),
340
+ .describe("NAME of the environment variable holding the incoming-webhook URL, listed in tool_config.notify.allowed_secret_envs. The URL is the credential — a Slack /services/… or Discord /api/webhooks/… path grants posting rights to anyone holding it — so it is named, never passed, and never printed in an error"),
249
341
  apiBaseUrl: z
250
342
  .string()
251
343
  .min(1)
@@ -256,7 +348,7 @@ const chatTargetShape = {
256
348
  .min(1)
257
349
  .max(64)
258
350
  .optional()
259
- .describe("NAME of the environment variable holding the bot token, for API mode"),
351
+ .describe("NAME of the environment variable holding the bot token, for API mode; it must be listed in tool_config.notify.allowed_secret_envs. It is sent as `Authorization: Bot <token>` to Discord and `Authorization: Bearer <token>` to Slack"),
260
352
  channel: z
261
353
  .string()
262
354
  .min(1)
@@ -264,6 +356,19 @@ const chatTargetShape = {
264
356
  .optional()
265
357
  .describe("channel or conversation id; required in API mode, ignored by an incoming webhook that is already bound to one"),
266
358
  };
359
+ /**
360
+ * `text` without its trailing slashes: what `text.replace(/\/+$/, "")`
361
+ * returned, in one pass from the end. That pattern tries every start inside
362
+ * a run of slashes that is not at the end and rescans the rest of the run
363
+ * from each, so it was quadratic in the run's length, and an API base URL is
364
+ * the model's.
365
+ */
366
+ function withoutTrailingSlashes(text) {
367
+ let end = text.length;
368
+ while (end > 0 && text.charCodeAt(end - 1) === 0x2f)
369
+ end--;
370
+ return end === text.length ? text : text.slice(0, end);
371
+ }
267
372
  /**
268
373
  * Work out which of the two ways to reach a platform this call is using, and
269
374
  * build the request that goes with it.
@@ -274,7 +379,7 @@ const chatTargetShape = {
274
379
  * configuration mistake rather than a preference, so it is refused instead
275
380
  * of resolved by precedence.
276
381
  */
277
- function routeChat(target, apiPath, allowWebhook) {
382
+ function routeChat(target, apiPath, allowWebhook, allowed) {
278
383
  const hasWebhook = target.webhookUrlEnv !== undefined;
279
384
  const hasApi = target.apiBaseUrl !== undefined || target.tokenEnv !== undefined;
280
385
  if (hasWebhook && hasApi) {
@@ -290,7 +395,7 @@ function routeChat(target, apiPath, allowWebhook) {
290
395
  message: "an incoming webhook can only create messages; editing, deleting and reacting need apiBaseUrl and tokenEnv",
291
396
  };
292
397
  }
293
- const resolved = webhookUrlFrom(target.webhookUrlEnv);
398
+ const resolved = webhookUrlFrom(target.webhookUrlEnv, allowed);
294
399
  if (typeof resolved === "string")
295
400
  return { ok: false, message: resolved };
296
401
  return {
@@ -311,16 +416,30 @@ function routeChat(target, apiPath, allowWebhook) {
311
416
  const base = parseUrl(target.apiBaseUrl);
312
417
  if (typeof base === "string")
313
418
  return { ok: false, message: base };
314
- const token = resolveSecret(target.tokenEnv, "the bot token");
419
+ const token = resolveSecret(target.tokenEnv, "the bot token", allowed);
315
420
  if (!token.ok)
316
421
  return { ok: false, message: token.message };
317
- const url = new URL(`${base.pathname.replace(/\/+$/, "")}/${apiPath.replace(/^\/+/, "")}`, base);
422
+ const literal = `${withoutTrailingSlashes(base.pathname)}/${apiPath.replace(/^\/+/, "")}`;
423
+ const url = new URL(literal, base);
424
+ // The path requested must be the path built: a segment the parser would
425
+ // resolve (a `..` channel, message id or emoji) addresses another endpoint.
426
+ if (url.pathname !== literal) {
427
+ return {
428
+ ok: false,
429
+ message: "a channel, message id or emoji in this call would change which API endpoint is requested, not just name the item",
430
+ };
431
+ }
432
+ // Discord authenticates a bot token with the `Bot` scheme; `Bearer` there
433
+ // means an OAuth2 user token, which cannot post, edit, delete or react as
434
+ // the bot, so every Discord API-mode call answered 401. Slack's bot token
435
+ // is a Bearer token.
436
+ const scheme = target.platform === "discord" ? "Bot" : "Bearer";
318
437
  return {
319
438
  ok: true,
320
439
  url,
321
440
  headers: {
322
441
  "content-type": "application/json; charset=utf-8",
323
- Authorization: `Bearer ${token.value}`,
442
+ Authorization: `${scheme} ${token.value}`,
324
443
  },
325
444
  secrets: [token.value],
326
445
  secretHeaders: new Set(["authorization"]),
@@ -345,6 +464,14 @@ function apiPathFor(platform, operation, channel, messageId, emoji) {
345
464
  }
346
465
  if (channel === undefined)
347
466
  return "discord addresses messages by channel id — set channel";
467
+ const dotted = [
468
+ ["channel", channel],
469
+ ["messageId", messageId],
470
+ ["emoji", emoji],
471
+ ].find(([, v]) => v !== undefined && isDotSegment(v));
472
+ if (dotted !== undefined) {
473
+ return `${dotted[0]} "${dotted[1]}" is a relative path segment, not a discord ${dotted[0] === "emoji" ? "emoji" : "id"} — substituted into the API path it would walk the request up to a different endpoint, such as the channel itself`;
474
+ }
348
475
  const base = `channels/${encodeURIComponent(channel)}/messages`;
349
476
  if (operation === "post")
350
477
  return { path: base, method: "POST" };
@@ -372,6 +499,14 @@ function renderMessage(platform, text, blocks) {
372
499
  }
373
500
  export const chatPost = buildTool({
374
501
  name: "ChatPost",
502
+ // Through an incoming webhook, the variable naming it: the webhook is bound
503
+ // to its channel, and the tool ignores `channel` there. In API mode, the
504
+ // channel on the API the call names. The webhook comes first, so an
505
+ // approval card names it.
506
+ operativeArgs: [
507
+ { field: "webhookUrlEnv", kind: "id" },
508
+ { field: "channel", kind: "recipient", service: "apiBaseUrl", ignoredWhen: "webhookUrlEnv" },
509
+ ],
375
510
  description: "Post a message to Slack, Discord, Microsoft Teams or a generic incoming webhook, with optional heading/paragraph/fields/divider/link blocks and a thread id where the platform threads. Use it when a harness needs to tell a room something rather than reply where it was spoken to — a nightly result, an alert, a hand-off. Every value is escaped for the platform before it is sent, so text carrying <!channel>, @everyone or a code fence can change what the message SAYS but never what it DOES; mentions are additionally suppressed at the API level. One tool covers four platforms because the payload differs and the intent does not. It takes an idempotency key so a retried call returns the first result instead of posting twice, and it will not follow a redirect — a webhook URL that moved is a configuration change, not something to chase at send time.",
376
511
  inputSchema: z.object({
377
512
  ...chatTargetShape,
@@ -399,9 +534,10 @@ export const chatPost = buildTool({
399
534
  requireJustification: true,
400
535
  execute: async (input, ctx) => {
401
536
  const args = input;
402
- const cached = ledgerLookup("ChatPost", args.idempotencyKey);
537
+ const fingerprint = requestFingerprint(args);
538
+ const cached = ledgerAnswer("ChatPost", args.idempotencyKey, fingerprint);
403
539
  if (cached !== undefined)
404
- return cached.result;
540
+ return cached;
405
541
  const rendered = renderMessage(args.platform, args.text, args.blocks);
406
542
  if (typeof rendered === "string")
407
543
  return notSentBecause(rendered);
@@ -418,7 +554,7 @@ export const chatPost = buildTool({
418
554
  apiPath = pathSpec.path;
419
555
  method = pathSpec.method;
420
556
  }
421
- const route = routeChat(args, apiPath, true);
557
+ const route = routeChat(args, apiPath, true, resolveNotifyConfig(ctx?.toolConfig).allowedSecretEnvs);
422
558
  if (!route.ok)
423
559
  return notSentBecause(route.message);
424
560
  if (route.mode === "api" && args.channel === undefined) {
@@ -451,18 +587,33 @@ export const chatPost = buildTool({
451
587
  const failure = platformFailure(args.platform, outcome);
452
588
  if (failure !== null)
453
589
  return prepared.redact(notSentBecause(failure));
590
+ const unknown = unknownVerdict(args.platform, outcome, route.mode);
591
+ if (unknown !== null) {
592
+ // Recorded like a success: the request was delivered, so a retry
593
+ // under the same key must not deliver it again.
594
+ const result = prepared.redact(json({
595
+ sent: null,
596
+ platform: args.platform,
597
+ mode: route.mode,
598
+ status: outcome.status,
599
+ reason: unknown,
600
+ }));
601
+ ledgerRecord("ChatPost", args.idempotencyKey, fingerprint, result);
602
+ return result;
603
+ }
454
604
  const result = prepared.redact(json({
455
605
  sent: true,
456
606
  platform: args.platform,
457
607
  mode: route.mode,
458
608
  status: outcome.status,
609
+ ...unreadableField(outcome),
459
610
  characters: rendered.text.length,
460
611
  ...(postedMessageId(args.platform, outcome) !== undefined
461
612
  ? { messageId: postedMessageId(args.platform, outcome) }
462
613
  : {}),
463
614
  ...(warnings.length > 0 ? { warnings: warnings.sort(byString) } : {}),
464
615
  }));
465
- ledgerRecord("ChatPost", args.idempotencyKey, result);
616
+ ledgerRecord("ChatPost", args.idempotencyKey, fingerprint, result);
466
617
  return result;
467
618
  }
468
619
  catch (err) {
@@ -475,6 +626,7 @@ export const chatPost = buildTool({
475
626
  });
476
627
  export const chatUpdate = buildTool({
477
628
  name: "ChatUpdate",
629
+ operativeArgs: [{ field: "channel", kind: "recipient", service: "apiBaseUrl" }],
478
630
  description: "Edit a message this harness already posted, by its id, on a platform that allows it. Use it to keep one status message current instead of adding a new one every cycle — a deploy that goes queued → running → done reads better as one edited line than as three. It needs a bot token: an incoming webhook cannot edit, and Teams and generic webhooks cannot edit at all, so both are refused rather than silently reposted. An edit still notifies nobody, but the message is in front of people, so it carries the same gate as posting. The previous text is not returned, because the platform does not give it back.",
479
631
  inputSchema: z.object({
480
632
  ...chatTargetShape,
@@ -503,7 +655,7 @@ export const chatUpdate = buildTool({
503
655
  const pathSpec = apiPathFor(args.platform, "update", args.channel, args.messageId, undefined);
504
656
  if (typeof pathSpec === "string")
505
657
  return notSentBecause(pathSpec);
506
- const route = routeChat(args, pathSpec.path, false);
658
+ const route = routeChat(args, pathSpec.path, false, resolveNotifyConfig(ctx?.toolConfig).allowedSecretEnvs);
507
659
  if (!route.ok)
508
660
  return notSentBecause(route.message);
509
661
  if (args.platform === "slack" && args.channel === undefined) {
@@ -523,11 +675,22 @@ export const chatUpdate = buildTool({
523
675
  const failure = platformFailure(args.platform, outcome);
524
676
  if (failure !== null)
525
677
  return prepared.redact(notSentBecause(failure));
678
+ const unknown = unknownVerdict(args.platform, outcome, "api");
679
+ if (unknown !== null) {
680
+ return prepared.redact(json({
681
+ updated: null,
682
+ platform: args.platform,
683
+ messageId: args.messageId,
684
+ status: outcome.status,
685
+ reason: unknown,
686
+ }));
687
+ }
526
688
  return prepared.redact(json({
527
689
  updated: true,
528
690
  platform: args.platform,
529
691
  messageId: args.messageId,
530
692
  status: outcome.status,
693
+ ...unreadableField(outcome),
531
694
  characters: rendered.text.length,
532
695
  ...(rendered.truncated
533
696
  ? { warnings: ["the message was cut to the platform limit"] }
@@ -544,6 +707,7 @@ export const chatUpdate = buildTool({
544
707
  });
545
708
  export const chatDelete = buildTool({
546
709
  name: "ChatDelete",
710
+ operativeArgs: [{ field: "messageId", kind: "id", within: "channel", service: "apiBaseUrl" }],
547
711
  description: "Remove a message this harness posted, by its id. Use it to retract something that was wrong, or to tidy a transient status line once the run is over. Deleting does not unsend: anyone watching the channel has already seen it, and on most platforms a tombstone or an audit entry remains, so this is a cleanup tool and not a way to take something back. It needs a bot token, works only where the platform supports deletion, and cannot delete a message this harness did not post unless the token's own permissions allow it.",
548
712
  inputSchema: z.object({
549
713
  ...chatTargetShape,
@@ -563,7 +727,7 @@ export const chatDelete = buildTool({
563
727
  const pathSpec = apiPathFor(args.platform, "delete", args.channel, args.messageId, undefined);
564
728
  if (typeof pathSpec === "string")
565
729
  return notSentBecause(pathSpec);
566
- const route = routeChat(args, pathSpec.path, false);
730
+ const route = routeChat(args, pathSpec.path, false, resolveNotifyConfig(ctx?.toolConfig).allowedSecretEnvs);
567
731
  if (!route.ok)
568
732
  return notSentBecause(route.message);
569
733
  if (args.platform === "slack" && args.channel === undefined) {
@@ -578,11 +742,13 @@ export const chatDelete = buildTool({
578
742
  const failure = platformFailure(args.platform, outcome);
579
743
  if (failure !== null)
580
744
  return prepared.redact(`the message was not deleted: ${failure}`);
745
+ const unknown = unknownVerdict(args.platform, outcome, "api");
581
746
  return prepared.redact(json({
582
- deleted: true,
747
+ deleted: unknown === null ? true : null,
583
748
  platform: args.platform,
584
749
  messageId: args.messageId,
585
750
  status: outcome.status,
751
+ ...(unknown === null ? unreadableField(outcome) : { reason: unknown }),
586
752
  }));
587
753
  }
588
754
  catch (err) {
@@ -595,6 +761,7 @@ export const chatDelete = buildTool({
595
761
  });
596
762
  export const chatReact = buildTool({
597
763
  name: "ChatReact",
764
+ operativeArgs: [{ field: "messageId", kind: "id", within: "channel", service: "apiBaseUrl" }],
598
765
  description: "Add an emoji reaction to a message, as the token's own user. Use it to acknowledge something cheaply — a ✅ on the alert that has been handled says as much as a reply and adds no noise. A reaction is visible to the room and shows who left it, so it counts as putting something in front of people and takes the same gate as a post. Give the emoji by name without colons on Slack, and as the literal character or name:id on Discord; an emoji the workspace does not have is refused by the platform, not invented here.",
599
766
  inputSchema: z.object({
600
767
  ...chatTargetShape,
@@ -619,7 +786,7 @@ export const chatReact = buildTool({
619
786
  const pathSpec = apiPathFor(args.platform, "react", args.channel, args.messageId, args.emoji);
620
787
  if (typeof pathSpec === "string")
621
788
  return notSentBecause(pathSpec);
622
- const route = routeChat(args, pathSpec.path, false);
789
+ const route = routeChat(args, pathSpec.path, false, resolveNotifyConfig(ctx?.toolConfig).allowedSecretEnvs);
623
790
  if (!route.ok)
624
791
  return notSentBecause(route.message);
625
792
  if (args.platform === "slack" && args.channel === undefined) {
@@ -638,12 +805,14 @@ export const chatReact = buildTool({
638
805
  const failure = platformFailure(args.platform, outcome);
639
806
  if (failure !== null)
640
807
  return prepared.redact(`the reaction was not added: ${failure}`);
808
+ const unknown = unknownVerdict(args.platform, outcome, "api");
641
809
  return prepared.redact(json({
642
- reacted: true,
810
+ reacted: unknown === null ? true : null,
643
811
  platform: args.platform,
644
812
  messageId: args.messageId,
645
813
  emoji: args.emoji,
646
814
  status: outcome.status,
815
+ ...(unknown === null ? unreadableField(outcome) : { reason: unknown }),
647
816
  }));
648
817
  }
649
818
  catch (err) {
@@ -796,13 +965,10 @@ function loadAttachments(toolName, specs) {
796
965
  }
797
966
  /** Build the message, or the readable reason it could not be built. */
798
967
  function buildMessage(toolName, args) {
799
- const when = new Date(args.date);
800
- if (Number.isNaN(when.getTime())) {
801
- return {
802
- ok: false,
803
- message: `"${args.date}" is not an instant this tool can read — use ISO-8601 with an offset, e.g. 2026-09-17T09:30:00Z`,
804
- };
805
- }
968
+ const instant = parseOffsetInstant(args.date);
969
+ if (!instant.ok)
970
+ return { ok: false, message: instant.message };
971
+ const when = new Date(instant.ms);
806
972
  const loaded = loadAttachments(toolName, args.attachments);
807
973
  if (!loaded.ok)
808
974
  return { ok: false, message: loaded.message };
@@ -835,6 +1001,7 @@ function buildMessage(toolName, args) {
835
1001
  messageId: composed.messageId,
836
1002
  bytes: composed.bytes,
837
1003
  envelopeTo: composed.envelopeTo,
1004
+ attachmentDigests: loaded.attachments.map((a) => createHash("sha256").update(a.content).digest("hex")),
838
1005
  };
839
1006
  }
840
1007
  export const emailCompose = buildTool({
@@ -857,8 +1024,42 @@ export const emailCompose = buildTool({
857
1024
  });
858
1025
  },
859
1026
  });
1027
+ /**
1028
+ * A retry of an EmailSend whose message can no longer be built — most often
1029
+ * because an attachment was a temp file cleaned up after the first send —
1030
+ * under a key that already sent a message agreeing on everything but the
1031
+ * attachments' bytes. Answering "nothing was sent" there would read as the
1032
+ * FIRST message being unsent, and invite a second send (net regression
1033
+ * review). So the recorded answer is given, with a note that this call sent
1034
+ * nothing and could not confirm the attachments are the same. Undefined when
1035
+ * the key recorded nothing like it.
1036
+ */
1037
+ function unconfirmedEmailReplay(args, reason) {
1038
+ const answer = ledgerLookup("EmailSend", args.idempotencyKey, requestFingerprint({ ...args, date: undefined }), ["attachmentContent"]);
1039
+ if (answer?.kind !== "replay")
1040
+ return undefined;
1041
+ const note = `idempotencyKey "${args.idempotencyKey}" already sent this message, and this is what that send returned. This call sent nothing: its message could not be built again (${reason}), so whether its attachments are the same as the ones sent is not known`;
1042
+ try {
1043
+ const recorded = JSON.parse(answer.result);
1044
+ if (typeof recorded === "object" && recorded !== null && !Array.isArray(recorded)) {
1045
+ return json({ ...recorded, replayNote: note });
1046
+ }
1047
+ }
1048
+ catch {
1049
+ // not JSON: the note goes on a line of its own
1050
+ }
1051
+ return `${answer.result}\n${note}`;
1052
+ }
860
1053
  export const emailSend = buildTool({
861
1054
  name: "EmailSend",
1055
+ // The files attached are sent with the message: a deny or ask reads them,
1056
+ // an allow on the recipients need not.
1057
+ operativeArgs: [
1058
+ { field: "to.address", kind: "recipient" },
1059
+ { field: "cc.address", kind: "recipient" },
1060
+ { field: "bcc.address", kind: "recipient" },
1061
+ { field: "attachments.path", kind: "path", source: true },
1062
+ ],
862
1063
  description: "Send a message over SMTP: EHLO, STARTTLS, AUTH, MAIL FROM, RCPT TO, DATA, written out rather than delegated. Use it when the thing to report belongs in somebody's inbox rather than in a chat room. STARTTLS is required by default and the session ends before the password is written if the server does not offer it; the credential is an environment variable NAME and the transcript records the AUTH line as redacted. Every recipient must match the operator's allow-list and the SMTP host must be one an operator named, both of which deny everything when unset. The whole session is deadline-bounded and the server's replies are byte-capped. It does not queue, retry, or track bounces — a refused recipient comes back named, and re-sending is the caller's decision.",
863
1064
  inputSchema: z.object({
864
1065
  ...composeShape,
@@ -879,13 +1080,13 @@ export const emailSend = buildTool({
879
1080
  .min(1)
880
1081
  .max(64)
881
1082
  .optional()
882
- .describe("NAME of the environment variable holding the SMTP username"),
1083
+ .describe("NAME of the environment variable holding the SMTP username, listed in tool_config.notify.allowed_secret_envs"),
883
1084
  passwordEnv: z
884
1085
  .string()
885
1086
  .min(1)
886
1087
  .max(64)
887
1088
  .optional()
888
- .describe("NAME of the environment variable holding the SMTP password"),
1089
+ .describe("NAME of the environment variable holding the SMTP password, listed in tool_config.notify.allowed_secret_envs"),
889
1090
  authMethod: z
890
1091
  .enum(["plain", "login", "auto"])
891
1092
  .optional()
@@ -905,9 +1106,6 @@ export const emailSend = buildTool({
905
1106
  requireJustification: true,
906
1107
  execute: async (input, ctx) => {
907
1108
  const args = input;
908
- const cached = ledgerLookup("EmailSend", args.idempotencyKey);
909
- if (cached !== undefined)
910
- return cached.result;
911
1109
  const cfg = resolveNotifyConfig(ctx?.toolConfig);
912
1110
  const everyone = [...(args.to ?? []), ...(args.cc ?? []), ...(args.bcc ?? [])];
913
1111
  if (everyone.length === 0)
@@ -928,19 +1126,38 @@ export const emailSend = buildTool({
928
1126
  if (args.usernameEnv === undefined || args.passwordEnv === undefined) {
929
1127
  return notSentBecause("set both usernameEnv and passwordEnv, or neither");
930
1128
  }
931
- const user = resolveSecret(args.usernameEnv, "the SMTP username");
1129
+ const user = resolveSecret(args.usernameEnv, "the SMTP username", cfg.allowedSecretEnvs);
932
1130
  if (!user.ok)
933
1131
  return notSentBecause(user.message);
934
- const pass = resolveSecret(args.passwordEnv, "the SMTP password");
1132
+ const pass = resolveSecret(args.passwordEnv, "the SMTP password", cfg.allowedSecretEnvs);
935
1133
  if (!pass.ok)
936
1134
  return notSentBecause(pass.message);
937
1135
  username = user.value;
938
1136
  password = pass.value;
939
1137
  }
940
1138
  const built = buildMessage("EmailSend", args);
941
- if (!built.ok)
942
- return notSentBecause(built.message);
943
- const redact = redactorFor([username, password]);
1139
+ if (!built.ok) {
1140
+ const replay = unconfirmedEmailReplay(args, built.message);
1141
+ return replay ?? notSentBecause(built.message);
1142
+ }
1143
+ // The ledger is asked about the message that would be SENT, so it is
1144
+ // consulted after the build. `date` only stamps the message, so a retry
1145
+ // that re-reads its clock is still the same message; everything that
1146
+ // says who gets what is compared, and an attachment by its bytes: a key
1147
+ // reused after report.txt was rewritten is a different message, not a
1148
+ // retry of the first (net review, C207). Consulting it before the build
1149
+ // replayed the first `sent: true` for a body that was never sent.
1150
+ const fingerprint = requestFingerprint({
1151
+ ...args,
1152
+ date: undefined,
1153
+ attachmentContent: built.attachmentDigests.length > 0 ? built.attachmentDigests : undefined,
1154
+ });
1155
+ const cached = ledgerAnswer("EmailSend", args.idempotencyKey, fingerprint);
1156
+ if (cached !== undefined)
1157
+ return cached;
1158
+ // The username is an identifier, redacted whole as 0.7.0 did; only the
1159
+ // password is a secret whose cut start is caught.
1160
+ const redact = redactorFor([password], [username]);
944
1161
  const deadline = startDeadline(args.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
945
1162
  try {
946
1163
  assertSmtpHostAllowed(args.host, cfg);
@@ -976,7 +1193,7 @@ export const emailSend = buildTool({
976
1193
  ...(outcome.rejected.length > 0 ? { rejected: outcome.rejected } : {}),
977
1194
  queued: outcome.queued,
978
1195
  }));
979
- ledgerRecord("EmailSend", args.idempotencyKey, result);
1196
+ ledgerRecord("EmailSend", args.idempotencyKey, fingerprint, result);
980
1197
  return result;
981
1198
  }
982
1199
  catch (err) {
@@ -1250,6 +1467,11 @@ export const emailSendPreflight = buildTool({
1250
1467
  // ---------------------------------------------------------------------------
1251
1468
  export const webhookPost = buildTool({
1252
1469
  name: "WebhookPost",
1470
+ // The URL, or the variable that holds one whose path is a secret.
1471
+ operativeArgs: [
1472
+ { field: "url", kind: "url" },
1473
+ { field: "urlEnv", kind: "id" },
1474
+ ],
1253
1475
  description: "POST a JSON payload to an allow-listed URL, optionally HMAC-signed, retrying only on 5xx and only on a backoff the call declares. Use it to hand an event to something that is not a chat platform — a pager, an internal receiver, a partner endpoint. Signing runs over the exact bytes transmitted, in either the timestamped (Stripe-style) or body-only (GitHub-style) scheme, and the timestamp is an argument rather than a clock reading so the same call signs the same way twice. The backoff is exponential with no jitter, because unseeded randomness makes a run unreproducible, and it never sleeps past the deadline. An idempotency key makes a retry safe end to end: the key travels to the receiver and a repeat call returns the first result instead of delivering again.",
1254
1476
  inputSchema: z.object({
1255
1477
  url: z
@@ -1262,7 +1484,7 @@ export const webhookPost = buildTool({
1262
1484
  .min(1)
1263
1485
  .max(64)
1264
1486
  .optional()
1265
- .describe("NAME of an environment variable holding the URL, for an endpoint whose path is itself a secret"),
1487
+ .describe("NAME of an environment variable holding the URL, for an endpoint whose path is itself a secret; it must be listed in tool_config.notify.allowed_secret_envs"),
1266
1488
  payload: z
1267
1489
  .union([z.record(z.unknown()), z.array(z.unknown()), z.string()])
1268
1490
  .describe("an object or array, sent as JSON, or a string sent verbatim — a signature covers exactly these bytes"),
@@ -1321,16 +1543,23 @@ export const webhookPost = buildTool({
1321
1543
  requireJustification: true,
1322
1544
  execute: async (input, ctx) => {
1323
1545
  const args = input;
1324
- const cached = ledgerLookup("WebhookPost", args.idempotencyKey);
1546
+ // A signature's timestamp only stamps the delivery; the payload and the
1547
+ // destination are what make it a different request.
1548
+ const fingerprint = requestFingerprint({
1549
+ ...args,
1550
+ signing: args.signing === undefined ? undefined : { ...args.signing, timestampSeconds: undefined },
1551
+ });
1552
+ const cached = ledgerAnswer("WebhookPost", args.idempotencyKey, fingerprint);
1325
1553
  if (cached !== undefined)
1326
- return cached.result;
1554
+ return cached;
1327
1555
  if ((args.url === undefined) === (args.urlEnv === undefined)) {
1328
1556
  return notSentBecause("give exactly one of url or urlEnv");
1329
1557
  }
1558
+ const allowedEnvs = resolveNotifyConfig(ctx?.toolConfig).allowedSecretEnvs;
1330
1559
  const urlSecrets = [];
1331
1560
  let url;
1332
1561
  if (args.urlEnv !== undefined) {
1333
- const resolved = webhookUrlFrom(args.urlEnv);
1562
+ const resolved = webhookUrlFrom(args.urlEnv, allowedEnvs);
1334
1563
  if (typeof resolved === "string")
1335
1564
  return notSentBecause(resolved);
1336
1565
  url = resolved.url;
@@ -1346,13 +1575,13 @@ export const webhookPost = buildTool({
1346
1575
  url = parsed;
1347
1576
  }
1348
1577
  const body = typeof args.payload === "string" ? args.payload : JSON.stringify(args.payload);
1349
- const headerPrep = prepareHeaders({ "content-type": "application/json", ...(args.headers ?? {}) }, args.auth);
1578
+ const headerPrep = prepareHeaders({ "content-type": "application/json", ...(args.headers ?? {}) }, args.auth, allowedEnvs);
1350
1579
  if (!headerPrep.ok)
1351
1580
  return notSentBecause(headerPrep.message);
1352
1581
  const headers = headerPrep.headers;
1353
1582
  const secrets = [...urlSecrets, ...headerPrep.secrets];
1354
1583
  if (args.signing !== undefined) {
1355
- const secret = resolveSecret(args.signing.secretEnv, "the signing secret");
1584
+ const secret = resolveSecret(args.signing.secretEnv, "the signing secret", allowedEnvs);
1356
1585
  if (!secret.ok)
1357
1586
  return notSentBecause(secret.message);
1358
1587
  secrets.push(secret.value);
@@ -1391,8 +1620,9 @@ export const webhookPost = buildTool({
1391
1620
  signed: args.signing !== undefined,
1392
1621
  ...(attempts.length > 0 ? { earlierAttempts: attempts } : {}),
1393
1622
  ...(outcome.body !== "" ? { response: outcome.body } : {}),
1623
+ ...unreadableField(outcome),
1394
1624
  }));
1395
- ledgerRecord("WebhookPost", args.idempotencyKey, result);
1625
+ ledgerRecord("WebhookPost", args.idempotencyKey, fingerprint, result);
1396
1626
  return result;
1397
1627
  }
1398
1628
  const retryable = outcome === null || outcome.status >= 500;
@@ -1402,8 +1632,16 @@ export const webhookPost = buildTool({
1402
1632
  ...(transportError !== null ? { error: transportError } : {}),
1403
1633
  });
1404
1634
  if (!retryable) {
1405
- return prepared.redact(notSentBecause(`${safeUrlLabel(url)} answered ${outcome.status}, which is not a retryable status: ${outcome.body.slice(0, 500)}`));
1635
+ return prepared.redact(notSentBecause(`${safeUrlLabel(url)} answered ${outcome.status}, which is not a retryable status: ${outcome.replyUnreadable ?? outcome.excerpt}`));
1406
1636
  }
1637
+ // Once the signal has aborted — a cancel, or the timer ahead of the
1638
+ // clock — no retry starts: each would fail at once, count as an
1639
+ // attempt, and spend the rest of `retries` in a few milliseconds. It
1640
+ // is asked before the retry count, so a cancel during the last
1641
+ // attempt reads as a cancel too, not as "all N attempt(s) failed".
1642
+ const stopped = () => prepared.redact(`${notSentBecause(describeFailure(prepared.deadline.signal.reason, prepared.deadline))}\n${json({ attempts })}`);
1643
+ if (prepared.deadline.signal.aborted)
1644
+ return stopped();
1407
1645
  if (attempt >= retries) {
1408
1646
  return prepared.redact(`${notSentBecause(`all ${attempt + 1} attempt(s) failed`)}\n${json({ attempts })}`);
1409
1647
  }
@@ -1412,6 +1650,8 @@ export const webhookPost = buildTool({
1412
1650
  return prepared.redact(`${notSentBecause(`the next backoff of ${delay}ms would outlast the deadline`)}\n${json({ attempts })}`);
1413
1651
  }
1414
1652
  await sleep(delay, prepared.deadline.signal);
1653
+ if (prepared.deadline.signal.aborted)
1654
+ return stopped();
1415
1655
  }
1416
1656
  }
1417
1657
  catch (err) {
@@ -1446,7 +1686,7 @@ function buildProviderCall(cfg, providerName, values, idempotencyKey) {
1446
1686
  if (typeof parsed === "string")
1447
1687
  return { ok: false, message: `provider "${providerName}" has an unusable endpoint` };
1448
1688
  const headers = {};
1449
- const applied = applyAuth(headers, profile.auth);
1689
+ const applied = applyAuth(headers, profile.auth, providerAuthEnvs(profile));
1450
1690
  if (!applied.ok)
1451
1691
  return { ok: false, message: applied.message };
1452
1692
  if (idempotencyKey !== undefined && profile.idempotencyHeader !== undefined) {
@@ -1485,23 +1725,38 @@ function buildProviderCall(cfg, providerName, values, idempotencyKey) {
1485
1725
  };
1486
1726
  }
1487
1727
  /** The shared execute body for SmsSend and PushNotify. */
1488
- async function runProviderSend(toolName, providerName, values, args, ctx) {
1489
- const cached = ledgerLookup(toolName, args.idempotencyKey);
1490
- if (cached !== undefined)
1491
- return cached.result;
1728
+ async function runProviderSend(toolName, kind, providerName, values, args, ctx) {
1492
1729
  const cfg = resolveNotifyConfig(ctx?.toolConfig);
1493
- const call = buildProviderCall(cfg, providerName, values, args.idempotencyKey);
1730
+ // The destination is checked first, before the ledger: a key reused for a
1731
+ // number the list no longer admits is refused, not answered from memory.
1732
+ // A provider that maps no `to` never sends the caller's value, so its
1733
+ // destination is whatever the operator pinned in staticFields.
1734
+ const profile = cfg.providers.get(providerName);
1735
+ if (profile?.fields?.["to"] !== undefined) {
1736
+ const refused = destinationRefusal(values["to"] ?? "", kind, cfg);
1737
+ if (refused !== null)
1738
+ return notSentBecause(refused);
1739
+ }
1740
+ // What is sent is the destination the list decided on, in the form it
1741
+ // compared: the number in E.164 form, not the call's spelling of it.
1742
+ const sending = { ...values, to: destinationFor(values["to"] ?? "", kind) };
1743
+ const fingerprint = requestFingerprint({ provider: providerName, ...sending });
1744
+ const cached = ledgerAnswer(toolName, args.idempotencyKey, fingerprint);
1745
+ if (cached !== undefined)
1746
+ return cached;
1747
+ const call = buildProviderCall(cfg, providerName, sending, args.idempotencyKey);
1494
1748
  if (!call.ok)
1495
1749
  return notSentBecause(call.message);
1496
1750
  const prepared = {
1497
1751
  cfg,
1498
1752
  deadline: startDeadline(args.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal),
1499
1753
  redact: redactorFor(call.secrets),
1754
+ secrets: call.secrets,
1500
1755
  };
1501
1756
  try {
1502
1757
  const outcome = await send(call.url, call.method, call.headers, call.body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, call.secretHeaders);
1503
1758
  if (!outcome.ok) {
1504
- return prepared.redact(notSentBecause(`${safeUrlLabel(call.url)} answered ${outcome.status}: ${outcome.body.slice(0, 500)}`));
1759
+ return prepared.redact(notSentBecause(`${safeUrlLabel(call.url)} answered ${outcome.status}: ${outcome.replyUnreadable ?? outcome.excerpt}`));
1505
1760
  }
1506
1761
  const profile = cfg.providers.get(providerName);
1507
1762
  const id = profile.idPath === undefined ? undefined : readPath(outcome.parsed, profile.idPath);
@@ -1512,8 +1767,9 @@ async function runProviderSend(toolName, providerName, values, args, ctx) {
1512
1767
  status: outcome.status,
1513
1768
  ...(typeof id === "string" || typeof id === "number" ? { messageId: String(id) } : {}),
1514
1769
  ...(typeof status === "string" ? { deliveryStatus: status } : {}),
1770
+ ...unreadableField(outcome),
1515
1771
  }));
1516
- ledgerRecord(toolName, args.idempotencyKey, result);
1772
+ ledgerRecord(toolName, args.idempotencyKey, fingerprint, result);
1517
1773
  return result;
1518
1774
  }
1519
1775
  catch (err) {
@@ -1525,6 +1781,7 @@ async function runProviderSend(toolName, providerName, values, args, ctx) {
1525
1781
  }
1526
1782
  export const smsSend = buildTool({
1527
1783
  name: "SmsSend",
1784
+ operativeArgs: [{ field: "to", kind: "recipient" }],
1528
1785
  description: "Send an SMS through a REST gateway the operator described in tool_config, rather than through a vendor this tool picked. Use it for the small number of notifications that must reach somebody who is not looking at a screen. The provider block names the endpoint, the auth environment variable and how the canonical fields to, body and from map onto that vendor's own names, so changing gateway is a config diff and not a release. An SMS costs money, arrives on a phone and cannot be recalled, so it is destructive, takes a justification, and takes an idempotency key that makes a retried call return the first result instead of sending a second message. It does not split a long message into parts or tell you what the carrier charged.",
1529
1786
  inputSchema: z.object({
1530
1787
  provider: z.string().min(1).max(100).describe("a name from tool_config.notify.providers"),
@@ -1532,7 +1789,7 @@ export const smsSend = buildTool({
1532
1789
  .string()
1533
1790
  .min(1)
1534
1791
  .max(100)
1535
- .describe("the destination number, in the format the provider expects (E.164 for most)"),
1792
+ .describe("the destination number; it must be in tool_config.notify.allowed_sms_recipients, which is matched in E.164 form (+ and digits; spaces, dashes, dots and parentheses are ignored), and it is sent in that form"),
1536
1793
  body: z
1537
1794
  .string()
1538
1795
  .min(1)
@@ -1554,11 +1811,12 @@ export const smsSend = buildTool({
1554
1811
  requireJustification: true,
1555
1812
  execute: async (input, ctx) => {
1556
1813
  const args = input;
1557
- return runProviderSend("SmsSend", args.provider, { to: args.to, body: args.body, from: args.from ?? "" }, args, ctx);
1814
+ return runProviderSend("SmsSend", "sms", args.provider, { to: args.to, body: args.body, from: args.from ?? "" }, args, ctx);
1558
1815
  },
1559
1816
  });
1560
1817
  export const pushNotify = buildTool({
1561
1818
  name: "PushNotify",
1819
+ operativeArgs: [{ field: "to", kind: "recipient" }],
1562
1820
  description: "Send a push notification through a REST provider the operator described in tool_config. Use it to reach an app or a device when a chat message would not be seen in time. It works exactly as SmsSend does — the provider block maps the canonical fields to, title, body and data onto the vendor's names — so one configuration style covers both, and a provider with no title concept simply never receives one. A push lands on a lock screen, so it is destructive and takes a justification; the idempotency key makes a retry safe. It does not manage device tokens, topics or subscriptions, and it reports what the provider answered rather than whether anyone read it.",
1563
1821
  inputSchema: z.object({
1564
1822
  provider: z.string().min(1).max(100).describe("a name from tool_config.notify.providers"),
@@ -1566,7 +1824,7 @@ export const pushNotify = buildTool({
1566
1824
  .string()
1567
1825
  .min(1)
1568
1826
  .max(500)
1569
- .describe("device token, topic or whatever the provider addresses"),
1827
+ .describe("device token, topic or whatever the provider addresses; it must be in tool_config.notify.allowed_push_targets"),
1570
1828
  title: z.string().max(200).optional(),
1571
1829
  body: z.string().min(1).max(4000),
1572
1830
  data: z
@@ -1587,7 +1845,7 @@ export const pushNotify = buildTool({
1587
1845
  for (const key of Object.keys(args.data ?? {}).sort(byString)) {
1588
1846
  ordered[key] = args.data[key];
1589
1847
  }
1590
- return runProviderSend("PushNotify", args.provider, {
1848
+ return runProviderSend("PushNotify", "push", args.provider, {
1591
1849
  to: args.to,
1592
1850
  title: args.title ?? "",
1593
1851
  body: args.body,
@@ -1597,6 +1855,7 @@ export const pushNotify = buildTool({
1597
1855
  });
1598
1856
  export const deliveryCheck = buildTool({
1599
1857
  name: "DeliveryCheck",
1858
+ operativeArgs: [{ field: "messageId", kind: "id", within: "provider" }],
1600
1859
  description: "Ask a provider what became of a message it accepted earlier, where its API answers that question. Use it after SmsSend or PushNotify to tell 'the gateway took it' apart from 'the handset got it', which are not the same thing and are reported hours apart. It reads the statusEndpoint and statusPath the operator configured for that provider, substituting the message id into the URL, and it only reads — this is the one outbound tool here that is readOnly and needs no justification. A provider with no status endpoint configured is reported as such rather than guessed at, and an unknown id is whatever the provider says it is.",
1601
1860
  inputSchema: z.object({
1602
1861
  provider: z.string().min(1).max(100).describe("a name from tool_config.notify.providers"),
@@ -1627,11 +1886,18 @@ export const deliveryCheck = buildTool({
1627
1886
  if (!/^[A-Za-z0-9_.:-]+$/.test(args.messageId)) {
1628
1887
  return "messageId must be a plain identifier (letters, digits, dot, underscore, colon or hyphen) — anything else could rewrite the URL it is substituted into";
1629
1888
  }
1630
- const parsed = parseUrl(profile.statusEndpoint.split("{id}").join(encodeURIComponent(args.messageId)));
1631
- if (typeof parsed === "string")
1632
- return `provider "${args.provider}" has an unusable statusEndpoint`;
1889
+ if (isDotSegment(args.messageId)) {
1890
+ return `messageId "${args.messageId}" is a relative path segment, not an id — substituted into the status URL it would read a different endpoint (a message list, or the API root), so nothing was sent`;
1891
+ }
1892
+ const substituted = substituteIntoUrl(profile.statusEndpoint, "{id}", args.messageId);
1893
+ if (!substituted.ok) {
1894
+ return substituted.why === "invalid"
1895
+ ? `provider "${args.provider}" has an unusable statusEndpoint`
1896
+ : `messageId "${args.messageId}" would change which endpoint the status URL addresses, not just name the message — nothing was sent`;
1897
+ }
1898
+ const parsed = substituted.url;
1633
1899
  const headers = {};
1634
- const applied = applyAuth(headers, profile.auth);
1900
+ const applied = applyAuth(headers, profile.auth, providerAuthEnvs(profile));
1635
1901
  if (!applied.ok)
1636
1902
  return applied.message;
1637
1903
  const cfgForCall = cfg;
@@ -1648,7 +1914,7 @@ export const deliveryCheck = buildTool({
1648
1914
  redirect: "follow",
1649
1915
  credentialHeaders: applied.secretHeaders,
1650
1916
  });
1651
- const capped = await readCapped(opened.res, args.maxBytes ?? DEFAULT_MAX_BYTES);
1917
+ const capped = await readCapped(opened.res, args.maxBytes ?? DEFAULT_MAX_BYTES, deadline.signal, applied.secrets);
1652
1918
  let parsedBody;
1653
1919
  try {
1654
1920
  parsedBody = JSON.parse(capped.text);
@@ -1814,6 +2080,7 @@ function describeDkim(answer) {
1814
2080
  }
1815
2081
  export const deliverabilityCheck = buildTool({
1816
2082
  name: "DeliverabilityCheck",
2083
+ operativeArgs: [{ field: "domain", kind: "recipient" }],
1817
2084
  description: "Read what a sending domain publishes about itself in public DNS — its SPF record, its DMARC policy, and the DKIM key record at each selector you name — and report what each one says. Use it to answer why mail from a domain is being refused or filtered, or to check a domain before a campaign leans on it. It reports facts rather than a score, because the facts differ in what you do next: no DMARC record at all and a DMARC record with p=none are the same score and completely different situations, and a lookup that failed is a third thing again — reported as unknown with its reason, never as 'nothing published'. It does NOT verify a message's DKIM signature: that needs canonicalisation this package does not implement, and a partial check that can answer 'pass' is worse than no check. Only domains an operator put in allowed_sender_domains are looked up.",
1818
2085
  inputSchema: z.object({
1819
2086
  domain: z
@@ -2031,11 +2298,10 @@ export const quietHours = buildTool({
2031
2298
  concurrencySafe: true,
2032
2299
  execute: async (input) => {
2033
2300
  const args = input;
2034
- const now = new Date(args.now);
2035
- if (Number.isNaN(now.getTime())) {
2036
- return `"${args.now}" is not an instant this tool can read — use ISO-8601 with an offset, e.g. 2026-09-17T03:14:00Z`;
2037
- }
2038
- const decision = quietDecision(args.schedule, now.getTime());
2301
+ const now = parseOffsetInstant(args.now, "2026-09-17T03:14:00Z");
2302
+ if (!now.ok)
2303
+ return now.message;
2304
+ const decision = quietDecision(args.schedule, now.ms);
2039
2305
  if ("error" in decision)
2040
2306
  return decision.error;
2041
2307
  return json(decision);
@@ -2049,7 +2315,7 @@ export const rateLimitGate = buildTool({
2049
2315
  .string()
2050
2316
  .min(1)
2051
2317
  .max(500)
2052
- .describe("what is being rate-limited: an alert name, a host, a customer id"),
2318
+ .describe('what is being rate-limited: an alert name, a host, a customer id (any string except "__proto__", which a JSON state record cannot hold)'),
2053
2319
  now: z
2054
2320
  .string()
2055
2321
  .min(1)
@@ -2081,13 +2347,19 @@ export const rateLimitGate = buildTool({
2081
2347
  concurrencySafe: true,
2082
2348
  execute: async (input) => {
2083
2349
  const args = input;
2084
- const now = new Date(args.now);
2085
- if (Number.isNaN(now.getTime())) {
2086
- return `"${args.now}" is not an instant this tool can read — use ISO-8601 with an offset, e.g. 2026-09-17T03:14:00Z`;
2350
+ const now = parseOffsetInstant(args.now, "2026-09-17T03:14:00Z");
2351
+ if (!now.ok)
2352
+ return now.message;
2353
+ // The state is a JSON record, and a JSON record cannot carry this one
2354
+ // key back: the input parser drops an own "__proto__" key, so the entry
2355
+ // this call wrote would be gone on the next call and the gate would say
2356
+ // "allowed" every time. Refused, so the gate never fails open.
2357
+ if (args.key === "__proto__") {
2358
+ return 'the key "__proto__" cannot be kept in a JSON state record, so it could never be limited — choose another, e.g. prefix it as "alert:__proto__"';
2087
2359
  }
2088
2360
  return json(evaluateRateLimit({
2089
2361
  key: args.key,
2090
- nowMs: now.getTime(),
2362
+ nowMs: now.ms,
2091
2363
  windowMs: args.windowMs,
2092
2364
  ...(args.limit !== undefined ? { limit: args.limit } : {}),
2093
2365
  ...(args.mode !== undefined ? { mode: args.mode } : {}),
@@ -2120,7 +2392,10 @@ export const messageTemplate = buildTool({
2120
2392
  concurrencySafe: true,
2121
2393
  execute: async (input) => {
2122
2394
  const args = input;
2123
- const template = args.templates[args.name];
2395
+ // Own names only: "constructor" or "toString" is not a template.
2396
+ const template = Object.hasOwn(args.templates, args.name)
2397
+ ? args.templates[args.name]
2398
+ : undefined;
2124
2399
  if (template === undefined) {
2125
2400
  const known = Object.keys(args.templates).sort(byString);
2126
2401
  return known.length === 0