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