@crewhaus/tool-notify 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2167 @@
1
+ /**
2
+ * `@crewhaus/tool-notify` — putting a message in front of a person, on
3
+ * purpose.
4
+ *
5
+ * `@crewhaus/tool-message-channel`'s `SendMessage` replies on the harness's
6
+ * OWN bound channel: the room it was spoken to in. That is the right
7
+ * primitive for a conversation, and it is not the one a scheduled job needs
8
+ * when the thing it has to say belongs in #ops, or in an email to the person
9
+ * who owns the broken service, or in an SMS at 03:00 because the disk is
10
+ * full. These tools address a destination an operator allow-listed, which is
11
+ * a different and more dangerous act, and they are built accordingly.
12
+ *
13
+ * Four commitments hold across the package.
14
+ *
15
+ * 1. **Every send is gated and justified.** Each sending tool is
16
+ * `destructive` AND `requireJustification`: putting text in front of a
17
+ * person is a side effect that cannot be undone by deleting the
18
+ * message, because it has already been read. The intent gate exists for
19
+ * exactly this, and no tool here opts out of it.
20
+ * 2. **The gate is tool-http's gate.** Allow-list, SSRF refusal, IP
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.
28
+ * 4. **Determinism, and no clock.** Listings sort, comparisons are
29
+ * locale-free, nothing is random — MIME boundaries are derived from the
30
+ * message's own content — and anything that needs "now" takes it as an
31
+ * argument. `QuietHours` deciding that 03:00 is quiet must not depend
32
+ * on when the test runs.
33
+ *
34
+ * The one deliberate exception to "same input, same output" is the
35
+ * idempotency ledger in `./net`: a second call carrying an
36
+ * `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.
39
+ */
40
+ import { readFileSync, statSync } from "node:fs";
41
+ import * as path from "node:path";
42
+ import { buildTool } from "@crewhaus/tool-builder";
43
+ import { z } from "zod";
44
+ import { EDITABLE_PLATFORMS, THREADED_PLATFORMS, buildChatPayload, clampToPlatform, renderBlocks, } from "./lib/blocks";
45
+ import { buildDigest, digestToBlocks, digestToText } from "./lib/digest";
46
+ import { normalizeDomain, normalizeSelector, parseDkim, parseDmarc, parseSpf, } from "./lib/dns-records";
47
+ import { composeMessage, isValidAddress } from "./lib/mime";
48
+ import { draftChecks, verdictFrom } from "./lib/preflight";
49
+ import { quietDecision } from "./lib/quiet";
50
+ import { rateLimitGate as evaluateRateLimit } from "./lib/ratelimit";
51
+ import { formatSignatureHeader, hmacHex, signedPayload } from "./lib/sign";
52
+ 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";
54
+ import { resolveSafe } from "./paths";
55
+ import { sendMail } from "./smtp";
56
+ export { NotifyPermissionError, _resetIdempotencyLedger, _resetNotifyConfig, _setDnsLookup, _setDnsTxtResolver, _setRawFetch, __setPrivateHostsAllowedForTest, buildNotifyConfig, canonicalizeOrigin, getNotifyConfig, registerNotifyConfig, } from "./net";
57
+ export { ToolPermissionError } from "./paths";
58
+ // ---------------------------------------------------------------------------
59
+ // shared schema pieces
60
+ // ---------------------------------------------------------------------------
61
+ const platformSchema = z
62
+ .enum(["slack", "discord", "teams", "webhook"])
63
+ .describe("which service the message is going to; it decides the payload shape, the escaping and whether threads, edits and reactions exist at all");
64
+ const blockSchema = z
65
+ .array(z.union([
66
+ z.object({ kind: z.literal("heading"), text: z.string().min(1) }),
67
+ z.object({ kind: z.literal("paragraph"), text: z.string() }),
68
+ z.object({
69
+ kind: z.literal("fields"),
70
+ fields: z
71
+ .array(z.object({ name: z.string(), value: z.string() }))
72
+ .min(1)
73
+ .max(25),
74
+ }),
75
+ z.object({ kind: z.literal("divider") }),
76
+ z.object({ kind: z.literal("link"), url: z.string().min(1), text: z.string().optional() }),
77
+ ]))
78
+ .max(50)
79
+ .optional()
80
+ .describe("a small structured body rendered per platform; every value is escaped so it cannot become markup, and a link whose URL is not http(s) is dropped rather than rendered");
81
+ const envVarSchema = (what) => z
82
+ .string()
83
+ .min(1)
84
+ .max(64)
85
+ .describe(`NAME of the environment variable holding ${what} — never the value itself`);
86
+ const timeoutSchema = z
87
+ .number()
88
+ .int()
89
+ .min(1)
90
+ .max(MAX_TIMEOUT_MS)
91
+ .optional()
92
+ .describe(`milliseconds before the send is abandoned (default ${DEFAULT_TIMEOUT_MS})`);
93
+ const maxBytesSchema = z
94
+ .number()
95
+ .int()
96
+ .min(256)
97
+ .max(MAX_MAX_BYTES)
98
+ .optional()
99
+ .describe(`cap in bytes on the response read back (default ${DEFAULT_MAX_BYTES}); the read is cut at the cap, never buffered past it`);
100
+ const idempotencySchema = z
101
+ .string()
102
+ .min(1)
103
+ .max(200)
104
+ .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");
106
+ const authSchema = z
107
+ .object({
108
+ type: z
109
+ .enum(["bearer", "basic", "header"])
110
+ .describe("bearer ⇒ Authorization: Bearer, basic ⇒ Authorization: Basic, header ⇒ headerName"),
111
+ envVar: envVarSchema("the secret"),
112
+ headerName: z.string().min(1).optional().describe('header to set when type is "header"'),
113
+ username: z
114
+ .string()
115
+ .optional()
116
+ .describe('username when type is "basic"; envVar is the password'),
117
+ prefix: z.string().optional().describe('literal prefix before the secret for type "header"'),
118
+ })
119
+ .optional()
120
+ .describe("credential resolved from the process environment at call time");
121
+ const mailboxSchema = z.object({
122
+ address: z.string().min(3).describe("an addr-spec, e.g. ops@example.com"),
123
+ name: z
124
+ .string()
125
+ .max(200)
126
+ .optional()
127
+ .describe("display name; encoded, never able to add a header"),
128
+ });
129
+ /** Everything that echoes out of this package passes through the redactor. */
130
+ function withRedaction(secrets, timeoutMs, ctx) {
131
+ return {
132
+ cfg: resolveNotifyConfig(ctx?.toolConfig),
133
+ deadline: startDeadline(timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal),
134
+ redact: redactorFor(secrets),
135
+ };
136
+ }
137
+ /** Reject inline credentials, then attach the auth profile's secret. */
138
+ function prepareHeaders(raw, auth) {
139
+ const headers = { ...(raw ?? {}) };
140
+ const inline = rejectInlineCredentials(headers);
141
+ if (inline !== null)
142
+ return { ok: false, message: inline };
143
+ const applied = applyAuth(headers, auth);
144
+ if (!applied.ok)
145
+ return { ok: false, message: applied.message };
146
+ return {
147
+ ok: true,
148
+ headers,
149
+ secretHeaders: applied.secretHeaders,
150
+ secrets: [...applied.secrets],
151
+ };
152
+ }
153
+ /** POST (or whatever verb) to an allow-listed URL and read a capped reply. */
154
+ async function send(url, method, headers, body, prepared, maxBytes, secretHeaders) {
155
+ const opened = await openRequest({
156
+ url,
157
+ method,
158
+ headers,
159
+ body,
160
+ signal: prepared.deadline.signal,
161
+ cfg: prepared.cfg,
162
+ redirect: "refuse",
163
+ credentialHeaders: secretHeaders,
164
+ });
165
+ const capped = await readCapped(opened.res, maxBytes);
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(capped.text);
169
+ }
170
+ catch {
171
+ parsed = undefined;
172
+ }
173
+ return {
174
+ status: opened.res.status,
175
+ ok: opened.res.status >= 200 && opened.res.status < 300,
176
+ body: capped.text,
177
+ truncated: capped.truncated,
178
+ parsed,
179
+ };
180
+ }
181
+ /**
182
+ * Slack answers `200 OK` with `{"ok":false,"error":"channel_not_found"}`.
183
+ * Treating HTTP status as the answer would report a lost message as sent, so
184
+ * the body is consulted for the one platform that works this way.
185
+ */
186
+ function platformFailure(platform, outcome) {
187
+ if (!outcome.ok) {
188
+ return `the service answered ${outcome.status}: ${outcome.body.slice(0, 500)}`;
189
+ }
190
+ if (platform === "slack" && typeof outcome.parsed === "object" && outcome.parsed !== null) {
191
+ const record = outcome.parsed;
192
+ if (record["ok"] === false) {
193
+ return `Slack accepted the request and refused the message: ${String(record["error"] ?? "unknown error")}`;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+ /** Where a platform keeps the id of the message just posted. */
199
+ function postedMessageId(platform, outcome) {
200
+ if (typeof outcome.parsed !== "object" || outcome.parsed === null)
201
+ return undefined;
202
+ const record = outcome.parsed;
203
+ const raw = platform === "slack" ? record["ts"] : record["id"];
204
+ return typeof raw === "string" ? raw : undefined;
205
+ }
206
+ /** Resolve a webhook URL held in an environment variable. */
207
+ function webhookUrlFrom(envVar) {
208
+ const resolved = resolveSecret(envVar, "the webhook URL");
209
+ if (!resolved.ok)
210
+ return resolved.message;
211
+ const parsed = parseUrl(resolved.value);
212
+ if (typeof parsed === "string") {
213
+ return `environment variable "${envVar}" does not hold an absolute URL (its value has not been echoed back, because a webhook URL is itself a credential)`;
214
+ }
215
+ return { url: parsed, secret: resolved.value };
216
+ }
217
+ /**
218
+ * The incoming-webhook URL shapes that ARE the credential.
219
+ *
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
222
+ * the transcript, the trace and usually an eval report — the same reason
223
+ * every other secret here is named rather than passed — so it is refused
224
+ * with the variable form pointed at instead.
225
+ */
226
+ const WEBHOOK_CREDENTIAL_PATHS = [
227
+ /^\/services\/T[^/]+\/B[^/]+\/.+/i,
228
+ /^\/api\/webhooks\/\d+\/.+/i,
229
+ /^\/webhookb2\/[^/]+@[^/]+\/IncomingWebhook\/.+/i,
230
+ ];
231
+ /** A refusal when `url` carries a credential in its path, else `null`. */
232
+ function inlineWebhookCredential(url) {
233
+ if (!WEBHOOK_CREDENTIAL_PATHS.some((shape) => shape.test(url.pathname)))
234
+ return null;
235
+ 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
+ }
237
+ const notSentBecause = (reason) => `nothing was sent: ${reason}`;
238
+ // ---------------------------------------------------------------------------
239
+ // chat
240
+ // ---------------------------------------------------------------------------
241
+ const chatTargetShape = {
242
+ platform: platformSchema,
243
+ webhookUrlEnv: z
244
+ .string()
245
+ .min(1)
246
+ .max(64)
247
+ .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"),
249
+ apiBaseUrl: z
250
+ .string()
251
+ .min(1)
252
+ .optional()
253
+ .describe('API root for token mode, e.g. "https://slack.com/api" or "https://discord.com/api/v10". Its origin must be allow-listed. Required for anything other than a plain post'),
254
+ tokenEnv: z
255
+ .string()
256
+ .min(1)
257
+ .max(64)
258
+ .optional()
259
+ .describe("NAME of the environment variable holding the bot token, for API mode"),
260
+ channel: z
261
+ .string()
262
+ .min(1)
263
+ .max(200)
264
+ .optional()
265
+ .describe("channel or conversation id; required in API mode, ignored by an incoming webhook that is already bound to one"),
266
+ };
267
+ /**
268
+ * Work out which of the two ways to reach a platform this call is using, and
269
+ * build the request that goes with it.
270
+ *
271
+ * `webhook` mode posts to an incoming-webhook URL held in an environment
272
+ * variable: simple, but it can only create messages. `api` mode carries a
273
+ * bot token and can edit, delete and react. Asking for both is a
274
+ * configuration mistake rather than a preference, so it is refused instead
275
+ * of resolved by precedence.
276
+ */
277
+ function routeChat(target, apiPath, allowWebhook) {
278
+ const hasWebhook = target.webhookUrlEnv !== undefined;
279
+ const hasApi = target.apiBaseUrl !== undefined || target.tokenEnv !== undefined;
280
+ if (hasWebhook && hasApi) {
281
+ return {
282
+ ok: false,
283
+ message: "give either webhookUrlEnv or apiBaseUrl+tokenEnv, not both — which credential to use is not something this tool should guess",
284
+ };
285
+ }
286
+ if (hasWebhook) {
287
+ if (!allowWebhook) {
288
+ return {
289
+ ok: false,
290
+ message: "an incoming webhook can only create messages; editing, deleting and reacting need apiBaseUrl and tokenEnv",
291
+ };
292
+ }
293
+ const resolved = webhookUrlFrom(target.webhookUrlEnv);
294
+ if (typeof resolved === "string")
295
+ return { ok: false, message: resolved };
296
+ return {
297
+ ok: true,
298
+ url: resolved.url,
299
+ headers: { "content-type": "application/json" },
300
+ secrets: [resolved.secret, resolved.url.pathname],
301
+ secretHeaders: new Set(),
302
+ mode: "webhook",
303
+ };
304
+ }
305
+ if (target.apiBaseUrl === undefined || target.tokenEnv === undefined) {
306
+ return {
307
+ ok: false,
308
+ message: "no destination: set webhookUrlEnv, or set both apiBaseUrl and tokenEnv",
309
+ };
310
+ }
311
+ const base = parseUrl(target.apiBaseUrl);
312
+ if (typeof base === "string")
313
+ return { ok: false, message: base };
314
+ const token = resolveSecret(target.tokenEnv, "the bot token");
315
+ if (!token.ok)
316
+ return { ok: false, message: token.message };
317
+ const url = new URL(`${base.pathname.replace(/\/+$/, "")}/${apiPath.replace(/^\/+/, "")}`, base);
318
+ return {
319
+ ok: true,
320
+ url,
321
+ headers: {
322
+ "content-type": "application/json; charset=utf-8",
323
+ Authorization: `Bearer ${token.value}`,
324
+ },
325
+ secrets: [token.value],
326
+ secretHeaders: new Set(["authorization"]),
327
+ mode: "api",
328
+ };
329
+ }
330
+ /** The API path for an operation on a platform, or a refusal. */
331
+ function apiPathFor(platform, operation, channel, messageId, emoji) {
332
+ if (platform === "teams" || platform === "webhook") {
333
+ if (operation === "post")
334
+ return { path: "", method: "POST" };
335
+ return `${platform === "teams" ? "Microsoft Teams" : "a generic webhook"} has no API for editing, deleting or reacting to a message that was posted this way — the message stands as sent`;
336
+ }
337
+ if (platform === "slack") {
338
+ if (operation === "post")
339
+ return { path: "chat.postMessage", method: "POST" };
340
+ if (operation === "update")
341
+ return { path: "chat.update", method: "POST" };
342
+ if (operation === "delete")
343
+ return { path: "chat.delete", method: "POST" };
344
+ return { path: "reactions.add", method: "POST" };
345
+ }
346
+ if (channel === undefined)
347
+ return "discord addresses messages by channel id — set channel";
348
+ const base = `channels/${encodeURIComponent(channel)}/messages`;
349
+ if (operation === "post")
350
+ return { path: base, method: "POST" };
351
+ if (messageId === undefined)
352
+ return "set messageId — discord identifies a message by its id";
353
+ if (operation === "update")
354
+ return { path: `${base}/${encodeURIComponent(messageId)}`, method: "PATCH" };
355
+ if (operation === "delete")
356
+ return { path: `${base}/${encodeURIComponent(messageId)}`, method: "DELETE" };
357
+ if (emoji === undefined)
358
+ return "set emoji";
359
+ return {
360
+ path: `${base}/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}/@me`,
361
+ method: "PUT",
362
+ };
363
+ }
364
+ /** Render text + blocks for a platform, refusing an empty message. */
365
+ function renderMessage(platform, text, blocks) {
366
+ const { rendered, dropped } = renderBlocks(platform, text, blocks ?? []);
367
+ if (rendered.trim() === "") {
368
+ return "the message is empty — give text, blocks, or both. A message nobody can read is still a notification somebody has to triage";
369
+ }
370
+ const clamped = clampToPlatform(platform, rendered);
371
+ return { text: clamped.text, dropped, truncated: clamped.truncated };
372
+ }
373
+ export const chatPost = buildTool({
374
+ name: "ChatPost",
375
+ 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
+ inputSchema: z.object({
377
+ ...chatTargetShape,
378
+ text: z.string().max(40_000).optional().describe("the message body; escaped for the platform"),
379
+ blocks: blockSchema,
380
+ threadId: z
381
+ .string()
382
+ .min(1)
383
+ .max(200)
384
+ .optional()
385
+ .describe("reply inside a thread: a Slack thread_ts. Ignored with a warning on a platform without threads"),
386
+ username: z
387
+ .string()
388
+ .min(1)
389
+ .max(80)
390
+ .optional()
391
+ .describe("override the posting name, where the platform allows it"),
392
+ idempotencyKey: idempotencySchema,
393
+ timeoutMs: timeoutSchema,
394
+ maxBytes: maxBytesSchema,
395
+ }),
396
+ scope: "external",
397
+ ioCapability: "network",
398
+ destructive: true,
399
+ requireJustification: true,
400
+ execute: async (input, ctx) => {
401
+ const args = input;
402
+ const cached = ledgerLookup("ChatPost", args.idempotencyKey);
403
+ if (cached !== undefined)
404
+ return cached.result;
405
+ const rendered = renderMessage(args.platform, args.text, args.blocks);
406
+ if (typeof rendered === "string")
407
+ return notSentBecause(rendered);
408
+ // The API path is only worked out when the call is actually using API
409
+ // mode. An incoming webhook URL already names the channel it posts to, so
410
+ // asking for a channel id there would refuse a call that is complete.
411
+ const wantsApi = args.apiBaseUrl !== undefined || args.tokenEnv !== undefined;
412
+ let apiPath = "";
413
+ let method = "POST";
414
+ if (wantsApi) {
415
+ const pathSpec = apiPathFor(args.platform, "post", args.channel, undefined, undefined);
416
+ if (typeof pathSpec === "string")
417
+ return notSentBecause(pathSpec);
418
+ apiPath = pathSpec.path;
419
+ method = pathSpec.method;
420
+ }
421
+ const route = routeChat(args, apiPath, true);
422
+ if (!route.ok)
423
+ return notSentBecause(route.message);
424
+ if (route.mode === "api" && args.channel === undefined) {
425
+ return notSentBecause("API mode addresses a channel — set channel");
426
+ }
427
+ const warnings = [...rendered.dropped];
428
+ if (args.threadId !== undefined && !THREADED_PLATFORMS.has(args.platform)) {
429
+ warnings.push(`${args.platform} has no threads; threadId was ignored`);
430
+ }
431
+ if (rendered.truncated)
432
+ warnings.push("the message was longer than the platform accepts and was cut");
433
+ const payload = buildChatPayload({
434
+ platform: args.platform,
435
+ text: rendered.text,
436
+ threadId: THREADED_PLATFORMS.has(args.platform) ? args.threadId : undefined,
437
+ channel: route.mode === "api" ? args.channel : undefined,
438
+ username: args.username,
439
+ });
440
+ // Discord's bot API keeps the thread reference outside the message body.
441
+ const url = new URL(route.url.toString());
442
+ if (args.platform === "discord" && route.mode === "api" && args.threadId !== undefined) {
443
+ url.searchParams.set("thread_id", args.threadId);
444
+ }
445
+ const prepared = withRedaction([...route.secrets], args.timeoutMs, ctx);
446
+ const headers = { ...route.headers };
447
+ if (args.idempotencyKey !== undefined)
448
+ headers["Idempotency-Key"] = args.idempotencyKey;
449
+ try {
450
+ const outcome = await send(url, method, headers, payload.body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, route.secretHeaders);
451
+ const failure = platformFailure(args.platform, outcome);
452
+ if (failure !== null)
453
+ return prepared.redact(notSentBecause(failure));
454
+ const result = prepared.redact(json({
455
+ sent: true,
456
+ platform: args.platform,
457
+ mode: route.mode,
458
+ status: outcome.status,
459
+ characters: rendered.text.length,
460
+ ...(postedMessageId(args.platform, outcome) !== undefined
461
+ ? { messageId: postedMessageId(args.platform, outcome) }
462
+ : {}),
463
+ ...(warnings.length > 0 ? { warnings: warnings.sort(byString) } : {}),
464
+ }));
465
+ ledgerRecord("ChatPost", args.idempotencyKey, result);
466
+ return result;
467
+ }
468
+ catch (err) {
469
+ return prepared.redact(notSentBecause(describeFailure(err, prepared.deadline)));
470
+ }
471
+ finally {
472
+ prepared.deadline.cancel();
473
+ }
474
+ },
475
+ });
476
+ export const chatUpdate = buildTool({
477
+ name: "ChatUpdate",
478
+ 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
+ inputSchema: z.object({
480
+ ...chatTargetShape,
481
+ messageId: z
482
+ .string()
483
+ .min(1)
484
+ .max(200)
485
+ .describe("the id the original post returned — a Slack ts, a Discord message id"),
486
+ text: z.string().max(40_000).optional(),
487
+ blocks: blockSchema,
488
+ timeoutMs: timeoutSchema,
489
+ maxBytes: maxBytesSchema,
490
+ }),
491
+ scope: "external",
492
+ ioCapability: "network",
493
+ destructive: true,
494
+ requireJustification: true,
495
+ execute: async (input, ctx) => {
496
+ const args = input;
497
+ if (!EDITABLE_PLATFORMS.has(args.platform)) {
498
+ return notSentBecause(`${args.platform} does not let a posted message be edited — the message stands as sent`);
499
+ }
500
+ const rendered = renderMessage(args.platform, args.text, args.blocks);
501
+ if (typeof rendered === "string")
502
+ return notSentBecause(rendered);
503
+ const pathSpec = apiPathFor(args.platform, "update", args.channel, args.messageId, undefined);
504
+ if (typeof pathSpec === "string")
505
+ return notSentBecause(pathSpec);
506
+ const route = routeChat(args, pathSpec.path, false);
507
+ if (!route.ok)
508
+ return notSentBecause(route.message);
509
+ if (args.platform === "slack" && args.channel === undefined) {
510
+ return notSentBecause("Slack identifies a message by channel plus ts — set channel");
511
+ }
512
+ const body = args.platform === "slack"
513
+ ? JSON.stringify({
514
+ channel: args.channel,
515
+ ts: args.messageId,
516
+ text: rendered.text,
517
+ link_names: false,
518
+ })
519
+ : JSON.stringify({ content: rendered.text, allowed_mentions: { parse: [] } });
520
+ const prepared = withRedaction([...route.secrets], args.timeoutMs, ctx);
521
+ try {
522
+ const outcome = await send(route.url, pathSpec.method, route.headers, body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, route.secretHeaders);
523
+ const failure = platformFailure(args.platform, outcome);
524
+ if (failure !== null)
525
+ return prepared.redact(notSentBecause(failure));
526
+ return prepared.redact(json({
527
+ updated: true,
528
+ platform: args.platform,
529
+ messageId: args.messageId,
530
+ status: outcome.status,
531
+ characters: rendered.text.length,
532
+ ...(rendered.truncated
533
+ ? { warnings: ["the message was cut to the platform limit"] }
534
+ : {}),
535
+ }));
536
+ }
537
+ catch (err) {
538
+ return prepared.redact(notSentBecause(describeFailure(err, prepared.deadline)));
539
+ }
540
+ finally {
541
+ prepared.deadline.cancel();
542
+ }
543
+ },
544
+ });
545
+ export const chatDelete = buildTool({
546
+ name: "ChatDelete",
547
+ 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
+ inputSchema: z.object({
549
+ ...chatTargetShape,
550
+ messageId: z.string().min(1).max(200).describe("the id the original post returned"),
551
+ timeoutMs: timeoutSchema,
552
+ maxBytes: maxBytesSchema,
553
+ }),
554
+ scope: "external",
555
+ ioCapability: "network",
556
+ destructive: true,
557
+ requireJustification: true,
558
+ execute: async (input, ctx) => {
559
+ const args = input;
560
+ if (!EDITABLE_PLATFORMS.has(args.platform)) {
561
+ return notSentBecause(`${args.platform} does not let a posted message be deleted`);
562
+ }
563
+ const pathSpec = apiPathFor(args.platform, "delete", args.channel, args.messageId, undefined);
564
+ if (typeof pathSpec === "string")
565
+ return notSentBecause(pathSpec);
566
+ const route = routeChat(args, pathSpec.path, false);
567
+ if (!route.ok)
568
+ return notSentBecause(route.message);
569
+ if (args.platform === "slack" && args.channel === undefined) {
570
+ return notSentBecause("Slack identifies a message by channel plus ts — set channel");
571
+ }
572
+ const body = args.platform === "slack"
573
+ ? JSON.stringify({ channel: args.channel, ts: args.messageId })
574
+ : undefined;
575
+ const prepared = withRedaction([...route.secrets], args.timeoutMs, ctx);
576
+ try {
577
+ const outcome = await send(route.url, pathSpec.method, route.headers, body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, route.secretHeaders);
578
+ const failure = platformFailure(args.platform, outcome);
579
+ if (failure !== null)
580
+ return prepared.redact(`the message was not deleted: ${failure}`);
581
+ return prepared.redact(json({
582
+ deleted: true,
583
+ platform: args.platform,
584
+ messageId: args.messageId,
585
+ status: outcome.status,
586
+ }));
587
+ }
588
+ catch (err) {
589
+ return prepared.redact(`the message was not deleted: ${describeFailure(err, prepared.deadline)}`);
590
+ }
591
+ finally {
592
+ prepared.deadline.cancel();
593
+ }
594
+ },
595
+ });
596
+ export const chatReact = buildTool({
597
+ name: "ChatReact",
598
+ 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
+ inputSchema: z.object({
600
+ ...chatTargetShape,
601
+ messageId: z.string().min(1).max(200),
602
+ emoji: z
603
+ .string()
604
+ .min(1)
605
+ .max(100)
606
+ .describe('Slack: the short name without colons, e.g. "white_check_mark". Discord: the character, or name:id for a custom one'),
607
+ timeoutMs: timeoutSchema,
608
+ maxBytes: maxBytesSchema,
609
+ }),
610
+ scope: "external",
611
+ ioCapability: "network",
612
+ destructive: true,
613
+ requireJustification: true,
614
+ execute: async (input, ctx) => {
615
+ const args = input;
616
+ if (!EDITABLE_PLATFORMS.has(args.platform)) {
617
+ return notSentBecause(`${args.platform} has no reactions`);
618
+ }
619
+ const pathSpec = apiPathFor(args.platform, "react", args.channel, args.messageId, args.emoji);
620
+ if (typeof pathSpec === "string")
621
+ return notSentBecause(pathSpec);
622
+ const route = routeChat(args, pathSpec.path, false);
623
+ if (!route.ok)
624
+ return notSentBecause(route.message);
625
+ if (args.platform === "slack" && args.channel === undefined) {
626
+ return notSentBecause("Slack identifies a message by channel plus ts — set channel");
627
+ }
628
+ const body = args.platform === "slack"
629
+ ? JSON.stringify({
630
+ channel: args.channel,
631
+ timestamp: args.messageId,
632
+ name: args.emoji.replace(/^:|:$/g, ""),
633
+ })
634
+ : undefined;
635
+ const prepared = withRedaction([...route.secrets], args.timeoutMs, ctx);
636
+ try {
637
+ const outcome = await send(route.url, pathSpec.method, route.headers, body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, route.secretHeaders);
638
+ const failure = platformFailure(args.platform, outcome);
639
+ if (failure !== null)
640
+ return prepared.redact(`the reaction was not added: ${failure}`);
641
+ return prepared.redact(json({
642
+ reacted: true,
643
+ platform: args.platform,
644
+ messageId: args.messageId,
645
+ emoji: args.emoji,
646
+ status: outcome.status,
647
+ }));
648
+ }
649
+ catch (err) {
650
+ return prepared.redact(`the reaction was not added: ${describeFailure(err, prepared.deadline)}`);
651
+ }
652
+ finally {
653
+ prepared.deadline.cancel();
654
+ }
655
+ },
656
+ });
657
+ // ---------------------------------------------------------------------------
658
+ // email
659
+ // ---------------------------------------------------------------------------
660
+ /** Per-attachment and whole-message caps, so memory is bounded before a read. */
661
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
662
+ const MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
663
+ const attachmentSchema = z
664
+ .array(z.object({
665
+ path: z
666
+ .string()
667
+ .min(1)
668
+ .describe("workspace-relative path; a path resolving outside the workspace is refused"),
669
+ filename: z
670
+ .string()
671
+ .min(1)
672
+ .max(255)
673
+ .optional()
674
+ .describe("name the recipient sees; defaults to the file's own"),
675
+ contentType: z
676
+ .string()
677
+ .min(3)
678
+ .max(100)
679
+ .optional()
680
+ .describe('media type, e.g. "application/pdf" (default application/octet-stream)'),
681
+ contentId: z
682
+ .string()
683
+ .min(1)
684
+ .max(200)
685
+ .optional()
686
+ .describe('cid: reference, for an image the HTML part embeds, e.g. "logo@crewhaus" — a plain token, since this tool adds the angle brackets and a whitespace or line break inside one would open a header of its own'),
687
+ }))
688
+ .max(20)
689
+ .optional()
690
+ .describe(`files attached from the workspace; each is capped at ${MAX_ATTACHMENT_BYTES} bytes and the assembled message at ${MAX_MESSAGE_BYTES}, checked from the file's size before it is read so an oversized attachment never reaches memory`);
691
+ const composeShape = {
692
+ from: mailboxSchema.describe("the sender; its domain also supplies the Message-ID domain"),
693
+ to: z.array(mailboxSchema).max(100).optional(),
694
+ cc: z.array(mailboxSchema).max(100).optional(),
695
+ bcc: z
696
+ .array(mailboxSchema)
697
+ .max(100)
698
+ .optional()
699
+ .describe("blind recipients: they reach the envelope and never a header, which is what keeps them blind"),
700
+ replyTo: z.array(mailboxSchema).max(10).optional(),
701
+ subject: z
702
+ .string()
703
+ .max(1000)
704
+ .describe("encoded when it is not plain ASCII, which also neutralises a newline"),
705
+ text: z
706
+ .string()
707
+ .max(1_000_000)
708
+ .describe("the plain-text body; always present, because a text/plain part is what makes a message readable everywhere"),
709
+ html: z
710
+ .string()
711
+ .max(1_000_000)
712
+ .optional()
713
+ .describe("optional HTML alternative; sent alongside the text part, never instead of it"),
714
+ attachments: attachmentSchema,
715
+ date: z
716
+ .string()
717
+ .min(1)
718
+ .describe("REQUIRED instant for the Date header, ISO-8601 with an offset (e.g. 2026-09-17T09:30:00Z). This tool never reads the clock, so the caller decides what time the message claims"),
719
+ inReplyTo: z
720
+ .string()
721
+ .max(400)
722
+ .optional()
723
+ .describe("Message-ID being replied to, angle brackets included, e.g. <abc@example.com>. Exactly one id: a value carrying whitespace or a line break is refused, because a message id is emitted verbatim and would otherwise become a header of its own"),
724
+ references: z
725
+ .array(z.string().max(400))
726
+ .max(50)
727
+ .optional()
728
+ .describe("the thread's earlier Message-IDs, each in angle brackets; the same one-id-per-entry rule as inReplyTo"),
729
+ headers: z
730
+ .record(z.string())
731
+ .optional()
732
+ .describe("extra headers; names this tool builds itself are refused rather than duplicated"),
733
+ };
734
+ /**
735
+ * Read attachments through the path gate, checking each file's size BEFORE
736
+ * reading it. Checking after would mean a 2 GB file is in memory by the time
737
+ * the cap says no.
738
+ */
739
+ function loadAttachments(toolName, specs) {
740
+ const attachments = [];
741
+ let total = 0;
742
+ let encoded = 0;
743
+ for (const spec of specs ?? []) {
744
+ let safe;
745
+ try {
746
+ safe = resolveSafe(toolName, spec.path);
747
+ }
748
+ catch (err) {
749
+ return { ok: false, message: err.message };
750
+ }
751
+ let size;
752
+ try {
753
+ const stat = statSync(safe.real);
754
+ if (!stat.isFile())
755
+ return { ok: false, message: `"${spec.path}" is not a regular file` };
756
+ size = stat.size;
757
+ }
758
+ catch {
759
+ return { ok: false, message: `"${spec.path}" could not be read` };
760
+ }
761
+ if (size > MAX_ATTACHMENT_BYTES) {
762
+ return {
763
+ ok: false,
764
+ message: `"${spec.path}" is ${size} bytes, over the ${MAX_ATTACHMENT_BYTES}-byte per-attachment cap — refused before it was read`,
765
+ };
766
+ }
767
+ total += size;
768
+ // The cap is on the message that gets BUILT, and base64 is what the
769
+ // attachment costs there: four characters per three bytes, plus a CRLF
770
+ // every 57 source bytes. Counting the raw size would let 25 MB of files
771
+ // assemble a 34 MB string first and refuse it afterwards, which is the
772
+ // allocation the cap exists to prevent.
773
+ encoded += Math.ceil(size / 3) * 4 + Math.ceil(size / 57) * 2;
774
+ if (encoded > MAX_MESSAGE_BYTES) {
775
+ return {
776
+ ok: false,
777
+ message: `the attachments encode to more than the ${MAX_MESSAGE_BYTES}-byte message cap (base64 costs about a third more than the files themselves) — refused before they were read`,
778
+ };
779
+ }
780
+ let content;
781
+ try {
782
+ content = new Uint8Array(readFileSync(safe.real));
783
+ }
784
+ catch {
785
+ return { ok: false, message: `"${spec.path}" could not be read` };
786
+ }
787
+ attachments.push({
788
+ filename: spec.filename ?? path.basename(safe.abs),
789
+ contentType: spec.contentType ?? "application/octet-stream",
790
+ content,
791
+ disposition: spec.contentId === undefined ? "attachment" : "inline",
792
+ ...(spec.contentId !== undefined ? { contentId: spec.contentId } : {}),
793
+ });
794
+ }
795
+ return { ok: true, attachments, bytes: total };
796
+ }
797
+ /** Build the message, or the readable reason it could not be built. */
798
+ 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
+ }
806
+ const loaded = loadAttachments(toolName, args.attachments);
807
+ if (!loaded.ok)
808
+ return { ok: false, message: loaded.message };
809
+ const composed = composeMessage({
810
+ from: args.from,
811
+ to: args.to ?? [],
812
+ cc: args.cc ?? [],
813
+ bcc: args.bcc ?? [],
814
+ replyTo: args.replyTo ?? [],
815
+ subject: args.subject,
816
+ text: args.text,
817
+ ...(args.html !== undefined ? { html: args.html } : {}),
818
+ attachments: loaded.attachments,
819
+ date: when,
820
+ ...(args.inReplyTo !== undefined ? { inReplyTo: args.inReplyTo } : {}),
821
+ ...(args.references !== undefined ? { references: args.references } : {}),
822
+ ...(args.headers !== undefined ? { headers: args.headers } : {}),
823
+ });
824
+ if (!composed.ok)
825
+ return { ok: false, message: composed.error };
826
+ if (composed.bytes > MAX_MESSAGE_BYTES) {
827
+ return {
828
+ ok: false,
829
+ message: `the assembled message is ${composed.bytes} bytes, over the ${MAX_MESSAGE_BYTES}-byte cap`,
830
+ };
831
+ }
832
+ return {
833
+ ok: true,
834
+ message: composed.message,
835
+ messageId: composed.messageId,
836
+ bytes: composed.bytes,
837
+ envelopeTo: composed.envelopeTo,
838
+ };
839
+ }
840
+ export const emailCompose = buildTool({
841
+ name: "EmailCompose",
842
+ description: "Build a complete RFC 5322 message — headers, MIME structure, encodings, attachments — and return the bytes without sending anything. Use it to inspect exactly what would go out, to hand a message to a transport this package does not speak, or to test the assembly without a mail server in the loop. Non-ASCII subjects and display names become encoded words, which also means a newline in either can never split the header block and add a Bcc. Bodies are quoted-printable UTF-8, attachments base64, and the MIME boundary is derived from the message's own content rather than from a random number, so composing the same message twice gives byte-identical output. It reads attachments from the workspace and nothing else: no network, no clock — the Date header is an argument.",
843
+ inputSchema: z.object(composeShape),
844
+ scope: "internal",
845
+ readOnly: true,
846
+ concurrencySafe: true,
847
+ execute: async (input) => {
848
+ const args = input;
849
+ const built = buildMessage("EmailCompose", args);
850
+ if (!built.ok)
851
+ return `the message was not composed: ${built.message}`;
852
+ return json({
853
+ messageId: built.messageId,
854
+ bytes: built.bytes,
855
+ envelopeTo: built.envelopeTo,
856
+ message: built.message,
857
+ });
858
+ },
859
+ });
860
+ export const emailSend = buildTool({
861
+ name: "EmailSend",
862
+ 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
+ inputSchema: z.object({
864
+ ...composeShape,
865
+ host: z.string().min(1).max(255).describe("SMTP host; must appear in allowed_smtp_hosts"),
866
+ port: z
867
+ .number()
868
+ .int()
869
+ .min(1)
870
+ .max(65535)
871
+ .optional()
872
+ .describe("default 587 (submission); 465 means implicit TLS"),
873
+ requireTls: z
874
+ .boolean()
875
+ .optional()
876
+ .describe("default true. When true and the server offers no STARTTLS, the session ends before anything — credential, envelope or body — is sent"),
877
+ usernameEnv: z
878
+ .string()
879
+ .min(1)
880
+ .max(64)
881
+ .optional()
882
+ .describe("NAME of the environment variable holding the SMTP username"),
883
+ passwordEnv: z
884
+ .string()
885
+ .min(1)
886
+ .max(64)
887
+ .optional()
888
+ .describe("NAME of the environment variable holding the SMTP password"),
889
+ authMethod: z
890
+ .enum(["plain", "login", "auto"])
891
+ .optional()
892
+ .describe("default auto, which prefers PLAIN over LOGIN among what the server offers"),
893
+ ehloName: z
894
+ .string()
895
+ .min(1)
896
+ .max(255)
897
+ .optional()
898
+ .describe("the name announced in EHLO (default crewhaus.invalid): a domain or a bracketed address literal, since anything carrying a space or a line break would be read by SMTP as a second command"),
899
+ idempotencyKey: idempotencySchema,
900
+ timeoutMs: timeoutSchema,
901
+ }),
902
+ scope: "external",
903
+ ioCapability: "network",
904
+ destructive: true,
905
+ requireJustification: true,
906
+ execute: async (input, ctx) => {
907
+ const args = input;
908
+ const cached = ledgerLookup("EmailSend", args.idempotencyKey);
909
+ if (cached !== undefined)
910
+ return cached.result;
911
+ const cfg = resolveNotifyConfig(ctx?.toolConfig);
912
+ const everyone = [...(args.to ?? []), ...(args.cc ?? []), ...(args.bcc ?? [])];
913
+ if (everyone.length === 0)
914
+ return notSentBecause("no recipients");
915
+ const refused = everyone
916
+ .map((m) => m.address)
917
+ .filter((address) => !recipientAllowed(address, cfg))
918
+ .sort(byString);
919
+ if (refused.length > 0) {
920
+ return notSentBecause(`${refused.join(", ")} ${refused.length === 1 ? "is" : "are"} not in allowed_recipients (an empty allow-list refuses everyone). An operator adds a full address, or *@domain`);
921
+ }
922
+ if (!isValidAddress(args.from.address)) {
923
+ return notSentBecause(`"${args.from.address}" is not a deliverable sender address`);
924
+ }
925
+ let username;
926
+ let password;
927
+ if (args.usernameEnv !== undefined || args.passwordEnv !== undefined) {
928
+ if (args.usernameEnv === undefined || args.passwordEnv === undefined) {
929
+ return notSentBecause("set both usernameEnv and passwordEnv, or neither");
930
+ }
931
+ const user = resolveSecret(args.usernameEnv, "the SMTP username");
932
+ if (!user.ok)
933
+ return notSentBecause(user.message);
934
+ const pass = resolveSecret(args.passwordEnv, "the SMTP password");
935
+ if (!pass.ok)
936
+ return notSentBecause(pass.message);
937
+ username = user.value;
938
+ password = pass.value;
939
+ }
940
+ const built = buildMessage("EmailSend", args);
941
+ if (!built.ok)
942
+ return notSentBecause(built.message);
943
+ const redact = redactorFor([username, password]);
944
+ const deadline = startDeadline(args.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
945
+ try {
946
+ assertSmtpHostAllowed(args.host, cfg);
947
+ const pinnedIp = await assertNotSsrf(args.host);
948
+ const port = args.port ?? 587;
949
+ const outcome = await sendMail({
950
+ host: args.host,
951
+ port,
952
+ pinnedIp,
953
+ implicitTls: port === 465,
954
+ requireTls: args.requireTls ?? true,
955
+ rejectUnauthorized: true,
956
+ username,
957
+ password,
958
+ authMethod: args.authMethod ?? "auto",
959
+ ehloName: args.ehloName ?? "crewhaus.invalid",
960
+ envelopeFrom: args.from.address,
961
+ envelopeTo: built.envelopeTo,
962
+ message: built.message,
963
+ signal: deadline.signal,
964
+ maxReplyBytes: 64 * 1024,
965
+ });
966
+ if (!outcome.ok) {
967
+ return redact(`${notSentBecause(outcome.error)}\n${json({ transcript: outcome.transcript })}`);
968
+ }
969
+ const result = redact(json({
970
+ sent: true,
971
+ messageId: built.messageId,
972
+ bytes: built.bytes,
973
+ secured: outcome.secured,
974
+ authenticated: outcome.authenticated,
975
+ accepted: outcome.accepted,
976
+ ...(outcome.rejected.length > 0 ? { rejected: outcome.rejected } : {}),
977
+ queued: outcome.queued,
978
+ }));
979
+ ledgerRecord("EmailSend", args.idempotencyKey, result);
980
+ return result;
981
+ }
982
+ catch (err) {
983
+ return redact(notSentBecause(describeFailure(err, deadline)));
984
+ }
985
+ finally {
986
+ deadline.cancel();
987
+ }
988
+ },
989
+ });
990
+ /**
991
+ * Stat every attachment through the path gate, and keep going after the
992
+ * first problem.
993
+ *
994
+ * `loadAttachments` stops at the first fault because it is on the way to a
995
+ * send; this is the report, so each file gets its own row. Both resolve the
996
+ * path with `resolveSafe` and both stat `safe.real` — the location the read
997
+ * would actually land on, symlinks already followed. Statting the path the
998
+ * caller wrote and reading the one the resolver returns is how a file
999
+ * outside the workspace gets measured as one inside it.
1000
+ */
1001
+ function surveyAttachments(specs, perAttachmentBytes) {
1002
+ const rows = [];
1003
+ let bytes = 0;
1004
+ let allReadable = true;
1005
+ for (const spec of specs ?? []) {
1006
+ let safe;
1007
+ try {
1008
+ safe = resolveSafe("EmailSendPreflight", spec.path);
1009
+ }
1010
+ catch (err) {
1011
+ allReadable = false;
1012
+ rows.push({ path: spec.path, status: "fail", detail: err.message });
1013
+ continue;
1014
+ }
1015
+ let stat;
1016
+ try {
1017
+ stat = statSync(safe.real);
1018
+ }
1019
+ catch (err) {
1020
+ // Missing, unreadable and "a directory component is not a directory"
1021
+ // are different answers, and none of them is "the attachment is fine".
1022
+ // The errno is carried through rather than flattened, because it is
1023
+ // what tells the caller whether to fix a path or a permission.
1024
+ allReadable = false;
1025
+ const code = err.code ?? "unknown";
1026
+ rows.push({
1027
+ path: spec.path,
1028
+ status: "unknown",
1029
+ detail: `could not be read (${code}), so whether this message can be sent is not known`,
1030
+ });
1031
+ continue;
1032
+ }
1033
+ if (!stat.isFile()) {
1034
+ allReadable = false;
1035
+ rows.push({ path: spec.path, status: "fail", detail: "is not a regular file" });
1036
+ continue;
1037
+ }
1038
+ bytes += stat.size;
1039
+ rows.push({
1040
+ path: spec.path,
1041
+ status: stat.size > perAttachmentBytes ? "fail" : "pass",
1042
+ detail: stat.size > perAttachmentBytes
1043
+ ? `${stat.size} bytes, over the ${perAttachmentBytes}-byte per-attachment budget`
1044
+ : `${stat.size} bytes`,
1045
+ bytes: stat.size,
1046
+ });
1047
+ }
1048
+ return { rows, bytes, allReadable };
1049
+ }
1050
+ export const emailSendPreflight = buildTool({
1051
+ name: "EmailSendPreflight",
1052
+ description: "Check a message the way EmailSend would build it, and send nothing. Use it before a send to find out in one call what would otherwise come back one refusal at a time: whether the From parses, whether there is an envelope at all, whether a recipient is outside the operator's allow-list, whether a header would be refused, whether the body is accidentally empty, whether a {{placeholder}} never got filled, and whether the attachments are readable and inside the size budget you state. It takes the SAME arguments as EmailSend and runs the SAME composer, so a message this reports as ready is a message that assembles; the Message-ID and byte count it returns are the real ones. It never opens a socket — not to a mail server, not to DNS — and a check that could not run comes back as unknown with its reason rather than as a pass.",
1053
+ inputSchema: z.object({
1054
+ ...composeShape,
1055
+ host: z
1056
+ .string()
1057
+ .min(1)
1058
+ .max(255)
1059
+ .optional()
1060
+ .describe("the SMTP host the send would use, checked against allowed_smtp_hosts only — no connection is made and no name is resolved"),
1061
+ maxMessageBytes: z
1062
+ .number()
1063
+ .int()
1064
+ .min(1)
1065
+ .max(MAX_MESSAGE_BYTES)
1066
+ .optional()
1067
+ .describe(`the budget the assembled message must fit, e.g. the receiving provider's limit (default ${MAX_MESSAGE_BYTES}, which is this package's own cap)`),
1068
+ maxAttachmentBytes: z
1069
+ .number()
1070
+ .int()
1071
+ .min(1)
1072
+ .max(MAX_ATTACHMENT_BYTES)
1073
+ .optional()
1074
+ .describe(`the budget any single attachment must fit (default ${MAX_ATTACHMENT_BYTES})`),
1075
+ }),
1076
+ scope: "internal",
1077
+ readOnly: true,
1078
+ concurrencySafe: true,
1079
+ execute: async (input, ctx) => {
1080
+ const args = input;
1081
+ const cfg = resolveNotifyConfig(ctx?.toolConfig);
1082
+ const messageBudget = args.maxMessageBytes ?? MAX_MESSAGE_BYTES;
1083
+ const attachmentBudget = args.maxAttachmentBytes ?? MAX_ATTACHMENT_BYTES;
1084
+ const checks = draftChecks({
1085
+ from: args.from,
1086
+ to: args.to ?? [],
1087
+ cc: args.cc ?? [],
1088
+ bcc: args.bcc ?? [],
1089
+ replyTo: args.replyTo ?? [],
1090
+ subject: args.subject,
1091
+ text: args.text,
1092
+ html: args.html,
1093
+ date: args.date,
1094
+ inReplyTo: args.inReplyTo,
1095
+ references: args.references,
1096
+ headers: args.headers,
1097
+ });
1098
+ // The same predicate EmailSend gates on, asked ahead of time. An empty
1099
+ // allow-list refuses everyone, and saying so here is the point.
1100
+ const everyone = [...(args.to ?? []), ...(args.cc ?? []), ...(args.bcc ?? [])];
1101
+ const refused = everyone
1102
+ .map((m) => m.address)
1103
+ .filter((address) => !recipientAllowed(address, cfg))
1104
+ .sort(byString);
1105
+ checks.push(everyone.length === 0
1106
+ ? {
1107
+ check: "recipients-allowed",
1108
+ status: "unknown",
1109
+ detail: "there are no recipients to check against allowed_recipients",
1110
+ }
1111
+ : refused.length === 0
1112
+ ? {
1113
+ check: "recipients-allowed",
1114
+ status: "pass",
1115
+ detail: `all ${everyone.length} recipients are in allowed_recipients`,
1116
+ }
1117
+ : {
1118
+ check: "recipients-allowed",
1119
+ status: "fail",
1120
+ detail: `${refused.join(", ")} ${refused.length === 1 ? "is" : "are"} not in allowed_recipients${cfg.allowedRecipients.length === 0 ? " (the allow-list is empty, which refuses everyone)" : ""}`,
1121
+ });
1122
+ if (args.host !== undefined) {
1123
+ try {
1124
+ assertSmtpHostAllowed(args.host, cfg);
1125
+ checks.push({
1126
+ check: "smtp-host",
1127
+ status: "pass",
1128
+ detail: `"${args.host}" is in allowed_smtp_hosts. Whether it resolves, and to what, is not checked here — that needs DNS`,
1129
+ });
1130
+ }
1131
+ catch (err) {
1132
+ checks.push({
1133
+ check: "smtp-host",
1134
+ status: "fail",
1135
+ detail: describeFailure(err),
1136
+ });
1137
+ }
1138
+ }
1139
+ const survey = surveyAttachments(args.attachments, attachmentBudget);
1140
+ // A "fail" row is either a path the gate refused or a file over the
1141
+ // stated budget; both are reported with their own reason rather than
1142
+ // summarised into one, because they are fixed differently.
1143
+ const refusedFiles = survey.rows.filter((row) => row.status === "fail");
1144
+ const unreadable = survey.rows.filter((row) => row.status === "unknown");
1145
+ // A refusal OUTRANKS an unknown, and this order is the whole point: one
1146
+ // refused path already decides the answer, and reporting "somebody has
1147
+ // to look" for a message that is settled is the same defect as reporting
1148
+ // an unanswered question as a pass, pointed the other way. The unknown
1149
+ // rows are still named, because they are a second thing to fix.
1150
+ const unreadableNames = unreadable.map((row) => `"${row.path}"`).join(", ");
1151
+ checks.push(survey.rows.length === 0
1152
+ ? { check: "attachments", status: "pass", detail: "no attachments" }
1153
+ : refusedFiles.length > 0
1154
+ ? {
1155
+ check: "attachments",
1156
+ status: "fail",
1157
+ detail: `${refusedFiles.map((row) => `"${row.path}": ${row.detail}`).join("; ")}${unreadable.length > 0 ? `; and ${unreadableNames} could not be read` : ""}`,
1158
+ }
1159
+ : unreadable.length > 0
1160
+ ? {
1161
+ check: "attachments",
1162
+ status: "unknown",
1163
+ detail: `${unreadableNames} could not be read, so the message cannot be judged`,
1164
+ }
1165
+ : {
1166
+ check: "attachments",
1167
+ status: "pass",
1168
+ detail: `${survey.rows.length} file${survey.rows.length === 1 ? "" : "s"}, ${survey.bytes} bytes before encoding`,
1169
+ });
1170
+ // Assembly is attempted only when every attachment could be read as
1171
+ // given: building the message without one would validate a DIFFERENT
1172
+ // message than the one that would be sent, and reporting it as ready
1173
+ // would be a lie about bytes nobody has seen.
1174
+ const built = survey.allReadable ? buildMessage("EmailSendPreflight", args) : null;
1175
+ if (built === null) {
1176
+ // Same precedence as the row above: a refused path is a settled "no",
1177
+ // and it stays a "no" however many other attachments were merely
1178
+ // unreadable. Only an unknown with NO refusal beside it is an unknown.
1179
+ const refusedAny = refusedFiles.length > 0;
1180
+ const reason = refusedAny ? "was refused" : "could not be read";
1181
+ checks.push({
1182
+ check: "assembly",
1183
+ status: refusedAny ? "fail" : "unknown",
1184
+ detail: `not attempted: an attachment ${reason}, so the bytes that would be sent cannot be built`,
1185
+ });
1186
+ checks.push({
1187
+ check: "size",
1188
+ status: "unknown",
1189
+ detail: "the message was not assembled, so its size is not known",
1190
+ });
1191
+ checks.push({
1192
+ check: "message-id",
1193
+ status: "unknown",
1194
+ detail: "the message was not assembled, so no Message-ID was derived",
1195
+ });
1196
+ }
1197
+ else if (!built.ok) {
1198
+ checks.push({
1199
+ check: "assembly",
1200
+ status: "fail",
1201
+ detail: `the composer refused this message: ${built.message}`,
1202
+ });
1203
+ checks.push({
1204
+ check: "size",
1205
+ status: "unknown",
1206
+ detail: "the message was not assembled, so its size is not known",
1207
+ });
1208
+ checks.push({
1209
+ check: "message-id",
1210
+ status: "unknown",
1211
+ detail: "the message was not assembled, so no Message-ID was derived",
1212
+ });
1213
+ }
1214
+ else {
1215
+ checks.push({
1216
+ check: "assembly",
1217
+ status: "pass",
1218
+ detail: `the message assembles, to ${built.envelopeTo.length} envelope recipient${built.envelopeTo.length === 1 ? "" : "s"}`,
1219
+ });
1220
+ checks.push(built.bytes > messageBudget
1221
+ ? {
1222
+ check: "size",
1223
+ status: "fail",
1224
+ detail: `${built.bytes} bytes, over the ${messageBudget}-byte budget`,
1225
+ }
1226
+ : {
1227
+ check: "size",
1228
+ status: "pass",
1229
+ detail: `${built.bytes} bytes, inside the ${messageBudget}-byte budget`,
1230
+ });
1231
+ checks.push({
1232
+ check: "message-id",
1233
+ status: "pass",
1234
+ detail: `${built.messageId}, derived from the message's own content and the sender's domain`,
1235
+ });
1236
+ }
1237
+ return json({
1238
+ verdict: verdictFrom(checks),
1239
+ checks,
1240
+ ...(survey.rows.length > 0 ? { attachments: survey.rows } : {}),
1241
+ ...(built?.ok === true
1242
+ ? { messageId: built.messageId, bytes: built.bytes, envelopeTo: built.envelopeTo }
1243
+ : {}),
1244
+ budget: { message: messageBudget, perAttachment: attachmentBudget },
1245
+ });
1246
+ },
1247
+ });
1248
+ // ---------------------------------------------------------------------------
1249
+ // webhook
1250
+ // ---------------------------------------------------------------------------
1251
+ export const webhookPost = buildTool({
1252
+ name: "WebhookPost",
1253
+ 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
+ inputSchema: z.object({
1255
+ url: z
1256
+ .string()
1257
+ .min(1)
1258
+ .optional()
1259
+ .describe("the endpoint; its origin must be allow-listed. An incoming-webhook URL whose path is itself the credential (Slack /services/…, Discord /api/webhooks/…, Teams /webhookb2/…) is refused here — name it in urlEnv instead"),
1260
+ urlEnv: z
1261
+ .string()
1262
+ .min(1)
1263
+ .max(64)
1264
+ .optional()
1265
+ .describe("NAME of an environment variable holding the URL, for an endpoint whose path is itself a secret"),
1266
+ payload: z
1267
+ .union([z.record(z.unknown()), z.array(z.unknown()), z.string()])
1268
+ .describe("an object or array, sent as JSON, or a string sent verbatim — a signature covers exactly these bytes"),
1269
+ headers: z
1270
+ .record(z.string())
1271
+ .optional()
1272
+ .describe("extra headers; Authorization, Proxy-Authorization and Cookie are refused here — use auth"),
1273
+ auth: authSchema,
1274
+ signing: z
1275
+ .object({
1276
+ secretEnv: envVarSchema("the signing secret"),
1277
+ scheme: z
1278
+ .enum(["timestamped", "body"])
1279
+ .describe("timestamped covers <seconds>.<body> and enables a replay window; body covers the body alone and has no replay protection"),
1280
+ algorithm: z.enum(["sha256", "sha1"]).optional().describe("default sha256"),
1281
+ header: z
1282
+ .string()
1283
+ .min(1)
1284
+ .max(100)
1285
+ .optional()
1286
+ .describe("header to carry the signature (default X-Signature)"),
1287
+ timestampSeconds: z
1288
+ .number()
1289
+ .int()
1290
+ .optional()
1291
+ .describe("REQUIRED for the timestamped scheme — this tool does not read the clock"),
1292
+ })
1293
+ .optional(),
1294
+ idempotencyKey: idempotencySchema,
1295
+ idempotencyHeader: z
1296
+ .string()
1297
+ .min(1)
1298
+ .max(100)
1299
+ .optional()
1300
+ .describe("header carrying the key (default Idempotency-Key)"),
1301
+ retries: z
1302
+ .number()
1303
+ .int()
1304
+ .min(0)
1305
+ .max(5)
1306
+ .optional()
1307
+ .describe("extra attempts after a 5xx or a transport failure (default 0)"),
1308
+ backoffMs: z
1309
+ .number()
1310
+ .int()
1311
+ .min(1)
1312
+ .max(60_000)
1313
+ .optional()
1314
+ .describe("first backoff; each retry doubles it (default 500)"),
1315
+ timeoutMs: timeoutSchema,
1316
+ maxBytes: maxBytesSchema,
1317
+ }),
1318
+ scope: "external",
1319
+ ioCapability: "network",
1320
+ destructive: true,
1321
+ requireJustification: true,
1322
+ execute: async (input, ctx) => {
1323
+ const args = input;
1324
+ const cached = ledgerLookup("WebhookPost", args.idempotencyKey);
1325
+ if (cached !== undefined)
1326
+ return cached.result;
1327
+ if ((args.url === undefined) === (args.urlEnv === undefined)) {
1328
+ return notSentBecause("give exactly one of url or urlEnv");
1329
+ }
1330
+ const urlSecrets = [];
1331
+ let url;
1332
+ if (args.urlEnv !== undefined) {
1333
+ const resolved = webhookUrlFrom(args.urlEnv);
1334
+ if (typeof resolved === "string")
1335
+ return notSentBecause(resolved);
1336
+ url = resolved.url;
1337
+ urlSecrets.push(resolved.secret, resolved.url.pathname);
1338
+ }
1339
+ else {
1340
+ const parsed = parseUrl(args.url);
1341
+ if (typeof parsed === "string")
1342
+ return notSentBecause(parsed);
1343
+ const credential = inlineWebhookCredential(parsed);
1344
+ if (credential !== null)
1345
+ return notSentBecause(credential);
1346
+ url = parsed;
1347
+ }
1348
+ const body = typeof args.payload === "string" ? args.payload : JSON.stringify(args.payload);
1349
+ const headerPrep = prepareHeaders({ "content-type": "application/json", ...(args.headers ?? {}) }, args.auth);
1350
+ if (!headerPrep.ok)
1351
+ return notSentBecause(headerPrep.message);
1352
+ const headers = headerPrep.headers;
1353
+ const secrets = [...urlSecrets, ...headerPrep.secrets];
1354
+ if (args.signing !== undefined) {
1355
+ const secret = resolveSecret(args.signing.secretEnv, "the signing secret");
1356
+ if (!secret.ok)
1357
+ return notSentBecause(secret.message);
1358
+ secrets.push(secret.value);
1359
+ if (args.signing.scheme === "timestamped" && args.signing.timestampSeconds === undefined) {
1360
+ return notSentBecause('the timestamped scheme signs "<seconds>.<body>", so it needs timestampSeconds — this tool does not read the clock, because a signature that changes on every call cannot be tested or replayed deliberately');
1361
+ }
1362
+ const algorithm = args.signing.algorithm ?? "sha256";
1363
+ const payloadToSign = signedPayload(args.signing.scheme, body, args.signing.timestampSeconds);
1364
+ headers[args.signing.header ?? "X-Signature"] = formatSignatureHeader(args.signing.scheme, hmacHex(secret.value, payloadToSign, algorithm), algorithm, args.signing.timestampSeconds);
1365
+ }
1366
+ if (args.idempotencyKey !== undefined) {
1367
+ headers[args.idempotencyHeader ?? "Idempotency-Key"] = args.idempotencyKey;
1368
+ }
1369
+ const prepared = withRedaction(secrets, args.timeoutMs, ctx);
1370
+ const retries = args.retries ?? 0;
1371
+ const backoffMs = args.backoffMs ?? 500;
1372
+ const attempts = [];
1373
+ try {
1374
+ for (let attempt = 0;; attempt++) {
1375
+ let outcome = null;
1376
+ let transportError = null;
1377
+ try {
1378
+ outcome = await send(url, "POST", headers, body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, headerPrep.secretHeaders);
1379
+ }
1380
+ catch (err) {
1381
+ if (err instanceof NotifyPermissionError)
1382
+ throw err;
1383
+ transportError = describeFailure(err, prepared.deadline);
1384
+ }
1385
+ if (outcome?.ok === true) {
1386
+ const result = prepared.redact(json({
1387
+ sent: true,
1388
+ status: outcome.status,
1389
+ attempts: attempt + 1,
1390
+ bytes: body.length,
1391
+ signed: args.signing !== undefined,
1392
+ ...(attempts.length > 0 ? { earlierAttempts: attempts } : {}),
1393
+ ...(outcome.body !== "" ? { response: outcome.body } : {}),
1394
+ }));
1395
+ ledgerRecord("WebhookPost", args.idempotencyKey, result);
1396
+ return result;
1397
+ }
1398
+ const retryable = outcome === null || outcome.status >= 500;
1399
+ attempts.push({
1400
+ attempt: attempt + 1,
1401
+ ...(outcome !== null ? { status: outcome.status } : {}),
1402
+ ...(transportError !== null ? { error: transportError } : {}),
1403
+ });
1404
+ if (!retryable) {
1405
+ return prepared.redact(notSentBecause(`${safeUrlLabel(url)} answered ${outcome.status}, which is not a retryable status: ${outcome.body.slice(0, 500)}`));
1406
+ }
1407
+ if (attempt >= retries) {
1408
+ return prepared.redact(`${notSentBecause(`all ${attempt + 1} attempt(s) failed`)}\n${json({ attempts })}`);
1409
+ }
1410
+ const delay = backoffMs * 2 ** attempt;
1411
+ if (delay >= prepared.deadline.remaining()) {
1412
+ return prepared.redact(`${notSentBecause(`the next backoff of ${delay}ms would outlast the deadline`)}\n${json({ attempts })}`);
1413
+ }
1414
+ await sleep(delay, prepared.deadline.signal);
1415
+ }
1416
+ }
1417
+ catch (err) {
1418
+ return prepared.redact(notSentBecause(describeFailure(err, prepared.deadline)));
1419
+ }
1420
+ finally {
1421
+ prepared.deadline.cancel();
1422
+ }
1423
+ },
1424
+ });
1425
+ /**
1426
+ * Turn a canonical field map into the request a named provider expects.
1427
+ *
1428
+ * Hard-coding a vendor here would mean a release every time somebody changes
1429
+ * gateway. The operator names the endpoint, the auth variable and the field
1430
+ * mapping in `tool_config.notify.providers`, and a canonical field with no
1431
+ * mapping is simply not sent — so a provider that has no `from` concept does
1432
+ * not receive an empty one.
1433
+ */
1434
+ function buildProviderCall(cfg, providerName, values, idempotencyKey) {
1435
+ const profile = cfg.providers.get(providerName);
1436
+ if (profile === undefined) {
1437
+ const known = [...cfg.providers.keys()].sort(byString);
1438
+ return {
1439
+ ok: false,
1440
+ message: known.length === 0
1441
+ ? `no provider "${providerName}": tool_config.notify.providers is empty, so there is nothing to send through`
1442
+ : `no provider "${providerName}"; configured providers are ${known.join(", ")}`,
1443
+ };
1444
+ }
1445
+ const parsed = parseUrl(profile.endpoint);
1446
+ if (typeof parsed === "string")
1447
+ return { ok: false, message: `provider "${providerName}" has an unusable endpoint` };
1448
+ const headers = {};
1449
+ const applied = applyAuth(headers, profile.auth);
1450
+ if (!applied.ok)
1451
+ return { ok: false, message: applied.message };
1452
+ if (idempotencyKey !== undefined && profile.idempotencyHeader !== undefined) {
1453
+ headers[profile.idempotencyHeader] = idempotencyKey;
1454
+ }
1455
+ const mapped = { ...(profile.staticFields ?? {}) };
1456
+ for (const [canonical, value] of Object.entries(values)) {
1457
+ const name = profile.fields?.[canonical];
1458
+ if (name === undefined || value === "")
1459
+ continue;
1460
+ mapped[name] = value;
1461
+ }
1462
+ let body;
1463
+ if ((profile.encoding ?? "json") === "form") {
1464
+ headers["content-type"] = "application/x-www-form-urlencoded";
1465
+ const form = new URLSearchParams();
1466
+ for (const key of Object.keys(mapped).sort(byString))
1467
+ form.set(key, mapped[key]);
1468
+ body = form.toString();
1469
+ }
1470
+ else {
1471
+ headers["content-type"] = "application/json";
1472
+ const ordered = {};
1473
+ for (const key of Object.keys(mapped).sort(byString))
1474
+ ordered[key] = mapped[key];
1475
+ body = JSON.stringify(ordered);
1476
+ }
1477
+ return {
1478
+ ok: true,
1479
+ url: parsed,
1480
+ method: profile.method ?? "POST",
1481
+ headers,
1482
+ body,
1483
+ secrets: [...applied.secrets],
1484
+ secretHeaders: applied.secretHeaders,
1485
+ };
1486
+ }
1487
+ /** 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;
1492
+ const cfg = resolveNotifyConfig(ctx?.toolConfig);
1493
+ const call = buildProviderCall(cfg, providerName, values, args.idempotencyKey);
1494
+ if (!call.ok)
1495
+ return notSentBecause(call.message);
1496
+ const prepared = {
1497
+ cfg,
1498
+ deadline: startDeadline(args.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal),
1499
+ redact: redactorFor(call.secrets),
1500
+ };
1501
+ try {
1502
+ const outcome = await send(call.url, call.method, call.headers, call.body, prepared, args.maxBytes ?? DEFAULT_MAX_BYTES, call.secretHeaders);
1503
+ if (!outcome.ok) {
1504
+ return prepared.redact(notSentBecause(`${safeUrlLabel(call.url)} answered ${outcome.status}: ${outcome.body.slice(0, 500)}`));
1505
+ }
1506
+ const profile = cfg.providers.get(providerName);
1507
+ const id = profile.idPath === undefined ? undefined : readPath(outcome.parsed, profile.idPath);
1508
+ const status = profile.statusPath === undefined ? undefined : readPath(outcome.parsed, profile.statusPath);
1509
+ const result = prepared.redact(json({
1510
+ sent: true,
1511
+ provider: providerName,
1512
+ status: outcome.status,
1513
+ ...(typeof id === "string" || typeof id === "number" ? { messageId: String(id) } : {}),
1514
+ ...(typeof status === "string" ? { deliveryStatus: status } : {}),
1515
+ }));
1516
+ ledgerRecord(toolName, args.idempotencyKey, result);
1517
+ return result;
1518
+ }
1519
+ catch (err) {
1520
+ return prepared.redact(notSentBecause(describeFailure(err, prepared.deadline)));
1521
+ }
1522
+ finally {
1523
+ prepared.deadline.cancel();
1524
+ }
1525
+ }
1526
+ export const smsSend = buildTool({
1527
+ name: "SmsSend",
1528
+ 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
+ inputSchema: z.object({
1530
+ provider: z.string().min(1).max(100).describe("a name from tool_config.notify.providers"),
1531
+ to: z
1532
+ .string()
1533
+ .min(1)
1534
+ .max(100)
1535
+ .describe("the destination number, in the format the provider expects (E.164 for most)"),
1536
+ body: z
1537
+ .string()
1538
+ .min(1)
1539
+ .max(2000)
1540
+ .describe("the message text; sent as-is, since SMS has no markup to escape"),
1541
+ from: z
1542
+ .string()
1543
+ .min(1)
1544
+ .max(100)
1545
+ .optional()
1546
+ .describe("sender id, where the provider takes one per message"),
1547
+ idempotencyKey: idempotencySchema,
1548
+ timeoutMs: timeoutSchema,
1549
+ maxBytes: maxBytesSchema,
1550
+ }),
1551
+ scope: "external",
1552
+ ioCapability: "network",
1553
+ destructive: true,
1554
+ requireJustification: true,
1555
+ execute: async (input, ctx) => {
1556
+ const args = input;
1557
+ return runProviderSend("SmsSend", args.provider, { to: args.to, body: args.body, from: args.from ?? "" }, args, ctx);
1558
+ },
1559
+ });
1560
+ export const pushNotify = buildTool({
1561
+ name: "PushNotify",
1562
+ 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
+ inputSchema: z.object({
1564
+ provider: z.string().min(1).max(100).describe("a name from tool_config.notify.providers"),
1565
+ to: z
1566
+ .string()
1567
+ .min(1)
1568
+ .max(500)
1569
+ .describe("device token, topic or whatever the provider addresses"),
1570
+ title: z.string().max(200).optional(),
1571
+ body: z.string().min(1).max(4000),
1572
+ data: z
1573
+ .record(z.string())
1574
+ .optional()
1575
+ .describe("extra key/value data, sent as one JSON string in the provider's data field"),
1576
+ idempotencyKey: idempotencySchema,
1577
+ timeoutMs: timeoutSchema,
1578
+ maxBytes: maxBytesSchema,
1579
+ }),
1580
+ scope: "external",
1581
+ ioCapability: "network",
1582
+ destructive: true,
1583
+ requireJustification: true,
1584
+ execute: async (input, ctx) => {
1585
+ const args = input;
1586
+ const ordered = {};
1587
+ for (const key of Object.keys(args.data ?? {}).sort(byString)) {
1588
+ ordered[key] = args.data[key];
1589
+ }
1590
+ return runProviderSend("PushNotify", args.provider, {
1591
+ to: args.to,
1592
+ title: args.title ?? "",
1593
+ body: args.body,
1594
+ data: args.data === undefined ? "" : JSON.stringify(ordered),
1595
+ }, args, ctx);
1596
+ },
1597
+ });
1598
+ export const deliveryCheck = buildTool({
1599
+ name: "DeliveryCheck",
1600
+ 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
+ inputSchema: z.object({
1602
+ provider: z.string().min(1).max(100).describe("a name from tool_config.notify.providers"),
1603
+ messageId: z
1604
+ .string()
1605
+ .min(1)
1606
+ .max(200)
1607
+ .describe("the id a send returned; substituted for {id} in statusEndpoint"),
1608
+ timeoutMs: timeoutSchema,
1609
+ maxBytes: maxBytesSchema,
1610
+ }),
1611
+ scope: "external",
1612
+ ioCapability: "network",
1613
+ readOnly: true,
1614
+ execute: async (input, ctx) => {
1615
+ const args = input;
1616
+ const cfg = resolveNotifyConfig(ctx?.toolConfig);
1617
+ const profile = cfg.providers.get(args.provider);
1618
+ if (profile === undefined) {
1619
+ const known = [...cfg.providers.keys()].sort(byString);
1620
+ return known.length === 0
1621
+ ? "tool_config.notify.providers is empty, so there is no provider to ask"
1622
+ : `no provider "${args.provider}"; configured providers are ${known.join(", ")}`;
1623
+ }
1624
+ if (profile.statusEndpoint === undefined) {
1625
+ return `provider "${args.provider}" has no statusEndpoint configured — this provider's API may not report delivery state at all, and this tool will not invent one`;
1626
+ }
1627
+ if (!/^[A-Za-z0-9_.:-]+$/.test(args.messageId)) {
1628
+ 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
+ }
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`;
1633
+ const headers = {};
1634
+ const applied = applyAuth(headers, profile.auth);
1635
+ if (!applied.ok)
1636
+ return applied.message;
1637
+ const cfgForCall = cfg;
1638
+ const deadline = startDeadline(args.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
1639
+ const redact = redactorFor([...applied.secrets]);
1640
+ try {
1641
+ assertOriginAllowed(parsed, cfgForCall);
1642
+ const opened = await openRequest({
1643
+ url: parsed,
1644
+ method: "GET",
1645
+ headers,
1646
+ signal: deadline.signal,
1647
+ cfg: cfgForCall,
1648
+ redirect: "follow",
1649
+ credentialHeaders: applied.secretHeaders,
1650
+ });
1651
+ const capped = await readCapped(opened.res, args.maxBytes ?? DEFAULT_MAX_BYTES);
1652
+ let parsedBody;
1653
+ try {
1654
+ parsedBody = JSON.parse(capped.text);
1655
+ }
1656
+ catch {
1657
+ parsedBody = undefined;
1658
+ }
1659
+ const state = profile.statusPath === undefined ? undefined : readPath(parsedBody, profile.statusPath);
1660
+ return redact(json({
1661
+ provider: args.provider,
1662
+ messageId: args.messageId,
1663
+ status: opened.res.status,
1664
+ ...(typeof state === "string" || typeof state === "number"
1665
+ ? { deliveryStatus: String(state) }
1666
+ : {}),
1667
+ ...(opened.credentialsDropped ? { credentialsDroppedAtRedirect: true } : {}),
1668
+ body: capped.text,
1669
+ ...(capped.truncated ? { truncated: true } : {}),
1670
+ }));
1671
+ }
1672
+ catch (err) {
1673
+ return redact(describeFailure(err, deadline));
1674
+ }
1675
+ finally {
1676
+ deadline.cancel();
1677
+ }
1678
+ },
1679
+ });
1680
+ // ---------------------------------------------------------------------------
1681
+ // what a sending domain publishes about itself
1682
+ // ---------------------------------------------------------------------------
1683
+ /** The four TXT outcomes, folded into the shape a record answer has. */
1684
+ function absentOrUnknown(answer, what) {
1685
+ if (answer.outcome === "unknown") {
1686
+ return {
1687
+ status: "unknown",
1688
+ detail: `${answer.reason} — this is not the same as "${what}", and nothing here should be read as one`,
1689
+ };
1690
+ }
1691
+ return {
1692
+ status: "absent",
1693
+ detail: answer.outcome === "nxdomain"
1694
+ ? "the name does not exist, so nothing is published there"
1695
+ : "the name exists and publishes no TXT record",
1696
+ };
1697
+ }
1698
+ /**
1699
+ * The SPF record as a result object.
1700
+ *
1701
+ * `absent` here means something narrower than the outer one: the domain DOES
1702
+ * publish TXT records and none of them is an SPF record. Keeping the two
1703
+ * apart matters when a domain's TXT set was replaced by something that
1704
+ * dropped the SPF line.
1705
+ */
1706
+ function describeSpf(answer) {
1707
+ if (answer.status === "absent") {
1708
+ return {
1709
+ status: "absent",
1710
+ detail: "the domain publishes TXT records and none of them is an SPF record",
1711
+ };
1712
+ }
1713
+ if (answer.status === "multiple") {
1714
+ return {
1715
+ status: "multiple",
1716
+ records: answer.records,
1717
+ detail: "more than one SPF record: RFC 7208 4.5 makes a receiver return permerror, which fails every sender rather than the wrong ones",
1718
+ };
1719
+ }
1720
+ if (answer.status === "unreadable") {
1721
+ return { status: "unreadable", record: answer.record, detail: answer.reason };
1722
+ }
1723
+ const parsed = answer.parsed;
1724
+ const notes = [...parsed.notes];
1725
+ if (parsed.dnsTermsHere > 0) {
1726
+ // Said plainly rather than left for the reader to assume: each include:
1727
+ // pulls in a record whose own terms count toward the same limit of ten,
1728
+ // and this tool does not follow them — it queries only the domain it was
1729
+ // given. A walked total that got the void-lookup and sub-limit rules
1730
+ // wrong would be a worse answer than an honest floor.
1731
+ notes.push("dnsTermsInThisRecord counts only the terms in this record; each include: or redirect= pulls in another record whose terms count toward the same limit of ten, and those are not followed");
1732
+ }
1733
+ return {
1734
+ status: "found",
1735
+ record: parsed.record,
1736
+ all: parsed.all,
1737
+ mechanisms: parsed.mechanisms,
1738
+ modifiers: parsed.modifiers,
1739
+ dnsTermsInThisRecord: parsed.dnsTermsHere,
1740
+ ...(notes.length > 0 ? { notes } : {}),
1741
+ };
1742
+ }
1743
+ function describeDmarc(answer) {
1744
+ if (answer.status === "absent") {
1745
+ return {
1746
+ status: "absent",
1747
+ // Stops at the name that was asked. "…so the domain has no DMARC
1748
+ // policy" was the conclusion here before, and it is one query short of
1749
+ // true: RFC 7489 6.6.3 has a receiver fall back to the ORGANIZATIONAL
1750
+ // domain's record, so a subdomain with nothing of its own can still be
1751
+ // governed by a p=reject two labels up. The note on the result says so
1752
+ // whenever that fallback could apply.
1753
+ detail: "there are TXT records at _dmarc and none of them begins with v=DMARC1, which RFC 7489 6.6.3 requires — so no DMARC record is published at this name",
1754
+ };
1755
+ }
1756
+ if (answer.status === "multiple") {
1757
+ return {
1758
+ status: "multiple",
1759
+ records: answer.records,
1760
+ detail: "more than one v=DMARC1 record: RFC 7489 6.6.3 says the domain is then treated as publishing none at all",
1761
+ };
1762
+ }
1763
+ if (answer.status === "unreadable") {
1764
+ return { status: "unreadable", record: answer.record, detail: answer.reason };
1765
+ }
1766
+ const parsed = answer.parsed;
1767
+ return {
1768
+ status: "found",
1769
+ record: parsed.record,
1770
+ policy: parsed.policy,
1771
+ subdomainPolicy: parsed.subdomainPolicy,
1772
+ percent: parsed.percent,
1773
+ tags: parsed.tags,
1774
+ ...(parsed.notes.length > 0 ? { notes: parsed.notes } : {}),
1775
+ };
1776
+ }
1777
+ /**
1778
+ * The DKIM KEY record as a result object.
1779
+ *
1780
+ * Note what is not in it: any claim about a signature. This describes the
1781
+ * key a selector publishes — its type, its size, whether it has been revoked
1782
+ * — which is what a caller can act on without a verifier. `mailauth` is what
1783
+ * would be needed to answer the other question, and half of that answer is
1784
+ * worse than none.
1785
+ */
1786
+ function describeDkim(answer) {
1787
+ if (answer.status === "absent") {
1788
+ return { status: "absent", detail: "no TXT record at this selector's _domainkey name" };
1789
+ }
1790
+ if (answer.status === "multiple") {
1791
+ return {
1792
+ status: "multiple",
1793
+ records: answer.records,
1794
+ detail: "more than one TXT record at this selector, so which key a receiver uses is not decided here",
1795
+ };
1796
+ }
1797
+ if (answer.status === "unreadable") {
1798
+ return { status: "unreadable", record: answer.record, detail: answer.reason };
1799
+ }
1800
+ const parsed = answer.parsed;
1801
+ // The verbatim record is NOT repeated here the way it is for SPF and
1802
+ // DMARC: a key record is exactly its tags, and `p` alone is a few hundred
1803
+ // characters, so printing both spends a caller's context twice on the same
1804
+ // base64. A record that could not be parsed is the case where the verbatim
1805
+ // text is the only thing worth having, and that branch above prints it.
1806
+ return {
1807
+ status: "found",
1808
+ keyType: parsed.keyType,
1809
+ revoked: parsed.revoked,
1810
+ ...(parsed.key === null ? {} : { key: parsed.key }),
1811
+ tags: parsed.tags,
1812
+ ...(parsed.notes.length > 0 ? { notes: parsed.notes } : {}),
1813
+ };
1814
+ }
1815
+ export const deliverabilityCheck = buildTool({
1816
+ name: "DeliverabilityCheck",
1817
+ 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
+ inputSchema: z.object({
1819
+ domain: z
1820
+ .string()
1821
+ .min(1)
1822
+ .max(253)
1823
+ .describe("the sending domain, e.g. example.com — the domain in the From address, not a recipient's. It must appear in tool_config.notify.allowed_sender_domains"),
1824
+ dkimSelectors: z
1825
+ .array(z.string().min(1).max(100))
1826
+ .max(10)
1827
+ .optional()
1828
+ .describe('selectors to look for, e.g. ["s1", "google"]. A DKIM key lives at <selector>._domainkey.<domain> and there is no way to enumerate selectors, so this tool checks the ones you name and guesses none'),
1829
+ timeoutMs: timeoutSchema,
1830
+ }),
1831
+ scope: "external",
1832
+ ioCapability: "network",
1833
+ readOnly: true,
1834
+ execute: async (input, ctx) => {
1835
+ const args = input;
1836
+ const cfg = resolveNotifyConfig(ctx?.toolConfig);
1837
+ // Parse first, then use the PARSED value everywhere: the allow-list is
1838
+ // checked against it, the `_dmarc.` and `._domainkey.` names are built
1839
+ // from it, and it is what comes back in the result. A gate that reads
1840
+ // one spelling while the resolver is handed another guards nothing.
1841
+ const domain = normalizeDomain(args.domain);
1842
+ if (!domain.ok)
1843
+ return `nothing was looked up: ${domain.reason}`;
1844
+ const selectors = [];
1845
+ for (const raw of args.dkimSelectors ?? []) {
1846
+ const selector = normalizeSelector(raw);
1847
+ if (!selector.ok)
1848
+ return `nothing was looked up: ${selector.reason}`;
1849
+ const name = `${selector.name}._domainkey.${domain.name}`;
1850
+ if (name.length > 253) {
1851
+ return `nothing was looked up: "${name}" is longer than the 253 octets a DNS name may carry`;
1852
+ }
1853
+ selectors.push(selector.name);
1854
+ }
1855
+ // Sorted and de-duplicated, so the same call asks the same questions in
1856
+ // the same order and a repeated selector is one lookup rather than two.
1857
+ const unique = [...new Set(selectors)].sort(byString);
1858
+ const deadline = startDeadline(args.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
1859
+ try {
1860
+ assertSenderDomainAllowed(domain.name, cfg);
1861
+ const names = [
1862
+ domain.name,
1863
+ `_dmarc.${domain.name}`,
1864
+ ...unique.map((selector) => `${selector}._domainkey.${domain.name}`),
1865
+ ];
1866
+ // In parallel, assembled by index: the answers are read back in the
1867
+ // order the names were built, so which lookup finished first cannot
1868
+ // change a byte of the result.
1869
+ const answers = await Promise.all(names.map((name) => lookupTxt(name, deadline)));
1870
+ const apex = answers[0];
1871
+ const dmarcAnswer = answers[1];
1872
+ const spf = apex.outcome === "records"
1873
+ ? describeSpf(parseSpf(apex.records))
1874
+ : absentOrUnknown(apex, "the domain publishes no SPF record");
1875
+ const dmarcParsed = dmarcAnswer.outcome === "records" ? parseDmarc(dmarcAnswer.records) : null;
1876
+ const dmarc = dmarcParsed === null
1877
+ ? absentOrUnknown(dmarcAnswer, "the domain publishes no DMARC record")
1878
+ : describeDmarc(dmarcParsed);
1879
+ // True when the lookup ANSWERED and the answer was "no policy here" —
1880
+ // never when the lookup itself failed, which is its own report.
1881
+ const noDmarcHere = dmarcParsed === null
1882
+ ? dmarcAnswer.outcome === "none" || dmarcAnswer.outcome === "nxdomain"
1883
+ : dmarcParsed.status === "absent" || dmarcParsed.status === "multiple";
1884
+ const dkim = unique.map((selector, index) => {
1885
+ const answer = answers[index + 2];
1886
+ return {
1887
+ selector,
1888
+ ...(answer.outcome === "records"
1889
+ ? describeDkim(parseDkim(answer.records))
1890
+ : absentOrUnknown(answer, "this selector publishes no key")),
1891
+ };
1892
+ });
1893
+ const notes = [];
1894
+ // "No DMARC record here" is not "no DMARC policy applies here". RFC
1895
+ // 7489 6.6.3 says a receiver that finds nothing at _dmarc.<name> asks
1896
+ // _dmarc.<organizational domain> next, so mail from a subdomain can be
1897
+ // covered by a policy this tool never queried. Naming the organizational
1898
+ // domain needs the Public Suffix List — `example.co.uk` is one and
1899
+ // `mail.example.com` is not, and nothing in a name says which — which
1900
+ // is a dependency this package does not take. So the gap is stated
1901
+ // rather than guessed at.
1902
+ if (noDmarcHere && domain.name.split(".").length > 2) {
1903
+ notes.push(`"${domain.name}" has more than two labels, so it may be a subdomain: RFC 7489 6.6.3 has a receiver fall back to the organizational domain's _dmarc record, which was not looked up here. Identifying that domain needs the Public Suffix List, which this package does not carry — ask for it by name to see its policy`);
1904
+ }
1905
+ if (apex.outcome === "nxdomain") {
1906
+ // Said as the fact it is, not as a conclusion about the other names:
1907
+ // each of those was asked separately and reports its own answer.
1908
+ notes.push("the domain itself returned NXDOMAIN, so there is no name for an SPF record to be published at");
1909
+ }
1910
+ if (unique.length === 0) {
1911
+ notes.push("no DKIM selector was named, so none was checked — a selector cannot be discovered from DNS, only asked for by name");
1912
+ }
1913
+ return json({
1914
+ domain: domain.name,
1915
+ spf,
1916
+ dmarc,
1917
+ dkim,
1918
+ lookups: names.map((name, index) => ({
1919
+ name,
1920
+ outcome: answers[index].outcome,
1921
+ })),
1922
+ ...(notes.length > 0 ? { notes } : {}),
1923
+ });
1924
+ }
1925
+ catch (err) {
1926
+ return describeFailure(err, deadline);
1927
+ }
1928
+ finally {
1929
+ deadline.cancel();
1930
+ }
1931
+ },
1932
+ });
1933
+ // ---------------------------------------------------------------------------
1934
+ // pure tools
1935
+ // ---------------------------------------------------------------------------
1936
+ export const notifyDigest = buildTool({
1937
+ name: "NotifyDigest",
1938
+ description: "Fold many events into one message: group them by key, count the repeats, keep the loudest groups and render the result as text or as blocks. Use it before any of the sending tools when a loop could produce more than one notification — this is what stops a watcher sending forty messages about the same broken host when one line saying '38× connection refused' carries more information. Totals always describe every event handed in, and a group that did not fit is reported as a count rather than dropped in silence, because a digest that quietly loses events is one nobody trusts. Pure: no network, no clock, and the same events always fold to the same bytes.",
1939
+ inputSchema: z.object({
1940
+ events: z
1941
+ .array(z.object({
1942
+ key: z
1943
+ .string()
1944
+ .min(1)
1945
+ .max(500)
1946
+ .describe("what makes two events the same; events sharing it are counted together"),
1947
+ summary: z
1948
+ .string()
1949
+ .max(1000)
1950
+ .optional()
1951
+ .describe("one line about this occurrence; the first seen becomes the group's line"),
1952
+ labels: z
1953
+ .record(z.string())
1954
+ .optional()
1955
+ .describe("distinct values are listed under the group, up to eight per label"),
1956
+ severity: z.enum(["info", "warning", "error", "critical"]).optional(),
1957
+ }))
1958
+ .max(5000),
1959
+ maxGroups: z
1960
+ .number()
1961
+ .int()
1962
+ .min(1)
1963
+ .max(100)
1964
+ .optional()
1965
+ .describe("how many groups to render (default 10)"),
1966
+ sort: z
1967
+ .enum(["count", "severity", "key"])
1968
+ .optional()
1969
+ .describe("default count, ties broken by key"),
1970
+ title: z.string().max(200).optional(),
1971
+ format: z
1972
+ .enum(["text", "blocks"])
1973
+ .optional()
1974
+ .describe("default text; blocks feed straight into ChatPost"),
1975
+ }),
1976
+ scope: "internal",
1977
+ readOnly: true,
1978
+ concurrencySafe: true,
1979
+ execute: async (input) => {
1980
+ const args = input;
1981
+ const digest = buildDigest(args.events, {
1982
+ ...(args.maxGroups !== undefined ? { maxGroups: args.maxGroups } : {}),
1983
+ ...(args.sort !== undefined ? { sort: args.sort } : {}),
1984
+ });
1985
+ return json({
1986
+ total: digest.total,
1987
+ distinct: digest.distinct,
1988
+ omittedGroups: digest.omittedGroups,
1989
+ omittedEvents: digest.omittedEvents,
1990
+ groups: digest.groups,
1991
+ ...(args.format === "blocks"
1992
+ ? { blocks: digestToBlocks(digest, args.title) }
1993
+ : { text: digestToText(digest, args.title) }),
1994
+ });
1995
+ },
1996
+ });
1997
+ export const quietHours = buildTool({
1998
+ name: "QuietHours",
1999
+ description: "Decide whether a notification may go out at a given instant under a schedule, and if not, when it next may. Use it before a send so a harness holds an overnight alert until the morning instead of waking somebody for something that will read the same at 09:00. Windows are weekday-and-time in a named IANA timezone and may wrap past midnight, blackout dates cover a whole local day, and DST is real rather than approximated — 22:00–07:00 is nine hours in November and eight on the spring-forward night. It never reads the clock: now is an argument, so the same question always gets the same answer and a Sunday in Tokyo is testable from anywhere.",
2000
+ inputSchema: z.object({
2001
+ schedule: z.object({
2002
+ timezone: z.string().min(1).max(100).describe("IANA name, e.g. Europe/Berlin"),
2003
+ quietWindows: z
2004
+ .array(z.object({
2005
+ days: z
2006
+ .array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]))
2007
+ .max(7)
2008
+ .optional()
2009
+ .describe("days the window STARTS on; omitted means every day"),
2010
+ start: z.string().min(4).max(5).describe("HH:MM local"),
2011
+ end: z
2012
+ .string()
2013
+ .min(4)
2014
+ .max(5)
2015
+ .describe("HH:MM local; earlier than start means the window wraps past midnight"),
2016
+ }))
2017
+ .max(50),
2018
+ blackoutDates: z
2019
+ .array(z.string().min(10).max(10))
2020
+ .max(200)
2021
+ .optional()
2022
+ .describe("YYYY-MM-DD local dates that are quiet all day"),
2023
+ }),
2024
+ now: z
2025
+ .string()
2026
+ .min(1)
2027
+ .describe("REQUIRED instant, ISO-8601 with an offset (e.g. 2026-09-17T03:14:00Z) — this tool does not read the clock"),
2028
+ }),
2029
+ scope: "internal",
2030
+ readOnly: true,
2031
+ concurrencySafe: true,
2032
+ execute: async (input) => {
2033
+ 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());
2039
+ if ("error" in decision)
2040
+ return decision.error;
2041
+ return json(decision);
2042
+ },
2043
+ });
2044
+ export const rateLimitGate = buildTool({
2045
+ name: "RateLimitGate",
2046
+ description: "Ask whether a key has already been notified inside a window, and get back both the decision and the state to persist. Use it to keep a loop from re-reporting the same thing every cycle: the harness keeps the returned state wherever its own state lives, hands it back next time, and the gate answers from it. The window is fixed rather than sliding, because a sliding window needs every timestamp retained and grows without bound in exactly the flood this exists to stop. Nothing is remembered inside the tool — a memory that dies with the process would let the flood return on the first restart — and nothing reads the clock: now is an argument. Expired keys are pruned out of the state it hands back, so the record cannot grow forever.",
2047
+ inputSchema: z.object({
2048
+ key: z
2049
+ .string()
2050
+ .min(1)
2051
+ .max(500)
2052
+ .describe("what is being rate-limited: an alert name, a host, a customer id"),
2053
+ now: z
2054
+ .string()
2055
+ .min(1)
2056
+ .describe("REQUIRED instant, ISO-8601 with an offset — this tool does not read the clock"),
2057
+ windowMs: z
2058
+ .number()
2059
+ .int()
2060
+ .min(1)
2061
+ .max(30 * 24 * 3_600_000)
2062
+ .describe("how long the window lasts"),
2063
+ limit: z
2064
+ .number()
2065
+ .int()
2066
+ .min(1)
2067
+ .max(1000)
2068
+ .optional()
2069
+ .describe("sends allowed per window (default 1 — tell me once)"),
2070
+ mode: z
2071
+ .enum(["consume", "peek"])
2072
+ .optional()
2073
+ .describe('default consume, which records the send. "peek" answers without changing the state'),
2074
+ state: z
2075
+ .record(z.object({ windowStart: z.number(), count: z.number(), last: z.number() }))
2076
+ .optional()
2077
+ .describe("the record returned by the previous call; omitted means a fresh one"),
2078
+ }),
2079
+ scope: "internal",
2080
+ readOnly: true,
2081
+ concurrencySafe: true,
2082
+ execute: async (input) => {
2083
+ 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`;
2087
+ }
2088
+ return json(evaluateRateLimit({
2089
+ key: args.key,
2090
+ nowMs: now.getTime(),
2091
+ windowMs: args.windowMs,
2092
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
2093
+ ...(args.mode !== undefined ? { mode: args.mode } : {}),
2094
+ ...(args.state !== undefined ? { state: args.state } : {}),
2095
+ }));
2096
+ },
2097
+ });
2098
+ export const messageTemplate = buildTool({
2099
+ name: "MessageTemplate",
2100
+ description: "Render a message from a named template and a data object, escaping every substituted value for the destination platform. Use it so the operator owns the message's structure and the run only supplies values: the template's own formatting is honoured, and a value containing <!channel>, @everyone or a code fence can change what the message says but never what it does. A missing key is an error rather than an empty string, because a notification reading 'deploy of failed at ' is worse than one that did not go out — somebody acts on it. Pure, with no clock and no network; it returns the text, which is what you hand to ChatPost.",
2101
+ inputSchema: z.object({
2102
+ templates: z
2103
+ .record(z.string().max(20_000))
2104
+ .describe("the operator's templates by name; structure lives here, never in the data"),
2105
+ name: z.string().min(1).max(200).describe("which template to render"),
2106
+ data: z
2107
+ .record(z.unknown())
2108
+ .describe("values for the {{placeholders}}; dotted paths read nested objects"),
2109
+ platform: platformSchema,
2110
+ maxLength: z
2111
+ .number()
2112
+ .int()
2113
+ .min(1)
2114
+ .max(100_000)
2115
+ .optional()
2116
+ .describe("cut the result to this many characters"),
2117
+ }),
2118
+ scope: "internal",
2119
+ readOnly: true,
2120
+ concurrencySafe: true,
2121
+ execute: async (input) => {
2122
+ const args = input;
2123
+ const template = args.templates[args.name];
2124
+ if (template === undefined) {
2125
+ const known = Object.keys(args.templates).sort(byString);
2126
+ return known.length === 0
2127
+ ? "no templates were given, so there is nothing to render"
2128
+ : `no template named "${args.name}"; the templates given are ${known.join(", ")}`;
2129
+ }
2130
+ const rendered = renderTemplateValues(template, args.data, args.platform);
2131
+ if (!rendered.ok) {
2132
+ return `the template was not rendered: it refers to ${rendered.missing.map((m) => `{{${m}}}`).join(", ")}, which the data does not supply. A missing value is an error here rather than an empty string, because a message with a hole in it still gets acted on`;
2133
+ }
2134
+ let text = rendered.text;
2135
+ let truncated = false;
2136
+ if (args.maxLength !== undefined && text.length > args.maxLength) {
2137
+ text = text.slice(0, args.maxLength);
2138
+ truncated = true;
2139
+ }
2140
+ return json({
2141
+ text,
2142
+ platform: args.platform,
2143
+ placeholders: rendered.used,
2144
+ ...(rendered.unused.length > 0 ? { unusedData: rendered.unused } : {}),
2145
+ ...(truncated ? { truncated: true } : {}),
2146
+ });
2147
+ },
2148
+ });
2149
+ /** Every tool this package registers, in the order a catalog should list them. */
2150
+ export const NOTIFY_TOOLS = Object.freeze([
2151
+ chatDelete,
2152
+ chatPost,
2153
+ chatReact,
2154
+ chatUpdate,
2155
+ deliverabilityCheck,
2156
+ deliveryCheck,
2157
+ emailCompose,
2158
+ emailSend,
2159
+ emailSendPreflight,
2160
+ messageTemplate,
2161
+ notifyDigest,
2162
+ pushNotify,
2163
+ quietHours,
2164
+ rateLimitGate,
2165
+ smsSend,
2166
+ webhookPost,
2167
+ ]);