@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/net.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
import { type SecretValue } from "@crewhaus/tool-safety/env";
|
|
2
3
|
/** Refusal by the allow-list, the SSRF gate, or a redirect rule. */
|
|
3
4
|
export declare class NotifyPermissionError extends CrewhausError {
|
|
4
5
|
readonly name = "NotifyPermissionError";
|
|
@@ -70,6 +71,23 @@ export type NotifyConfig = {
|
|
|
70
71
|
readonly allowedSenderDomains: ReadonlySet<string>;
|
|
71
72
|
/** Named REST providers for SMS, push and delivery lookups. */
|
|
72
73
|
readonly providers: ReadonlyMap<string, ProviderProfile>;
|
|
74
|
+
/**
|
|
75
|
+
* The environment variables a tool call may name as a credential: a bot
|
|
76
|
+
* token, a webhook URL, a WebhookPost auth or signing secret, SMTP
|
|
77
|
+
* credentials. Empty (the default) refuses every such name. A provider's
|
|
78
|
+
* own `auth.envVar` is operator-written and needs no listing, but it is
|
|
79
|
+
* read only for that provider, never by a name a call supplies.
|
|
80
|
+
*/
|
|
81
|
+
readonly allowedSecretEnvs: readonly string[];
|
|
82
|
+
/**
|
|
83
|
+
* The numbers `SmsSend` may address, in E.164 form: an exact number, or a
|
|
84
|
+
* prefix ending in `*` (`+44*`). Empty (the default) refuses every number,
|
|
85
|
+
* as `allowedRecipients` refuses every address: an SMS costs money, and a
|
|
86
|
+
* number the model chooses is the premium-rate pumping surface.
|
|
87
|
+
*/
|
|
88
|
+
readonly allowedSmsRecipients: readonly string[];
|
|
89
|
+
/** The device tokens or topics `PushNotify` may address: exact, or a `prefix*`. Empty refuses all. */
|
|
90
|
+
readonly allowedPushTargets: readonly string[];
|
|
73
91
|
};
|
|
74
92
|
export type NotifyConfigInput = {
|
|
75
93
|
readonly allowed_origins?: readonly string[];
|
|
@@ -81,9 +99,41 @@ export type NotifyConfigInput = {
|
|
|
81
99
|
readonly allowed_sender_domains?: readonly string[];
|
|
82
100
|
readonly allowedSenderDomains?: readonly string[];
|
|
83
101
|
readonly providers?: Readonly<Record<string, ProviderProfile>>;
|
|
102
|
+
readonly allowed_secret_envs?: readonly string[];
|
|
103
|
+
readonly allowedSecretEnvs?: readonly string[];
|
|
104
|
+
readonly allowed_sms_recipients?: readonly string[];
|
|
105
|
+
readonly allowedSmsRecipients?: readonly string[];
|
|
106
|
+
readonly allowed_push_targets?: readonly string[];
|
|
107
|
+
readonly allowedPushTargets?: readonly string[];
|
|
84
108
|
};
|
|
109
|
+
/** Where an operator allows a credential variable. Every refusal names it. */
|
|
110
|
+
export declare const SECRET_ENVS_KEY = "tool_config.notify.allowed_secret_envs";
|
|
85
111
|
/** Build a config from a spec-shaped block. Pure; both key spellings accepted. */
|
|
86
112
|
export declare function buildNotifyConfig(input: NotifyConfigInput): NotifyConfig;
|
|
113
|
+
/** Which allow-list a provider send's destination is checked against. */
|
|
114
|
+
export type DestinationKind = "sms" | "push";
|
|
115
|
+
/**
|
|
116
|
+
* A phone number as the SMS allow-list compares it: the separators people
|
|
117
|
+
* write (spaces, `-`, `.`, parentheses) removed wherever they are, so
|
|
118
|
+
* `+1 (555) 000-1111` and `+15550001111` are the same number. It is the
|
|
119
|
+
* permission matcher's own reading, so a deny rule decides on the number
|
|
120
|
+
* this tool checks and sends.
|
|
121
|
+
*/
|
|
122
|
+
export declare const normalizeSmsNumber: (raw: string) => string;
|
|
123
|
+
/**
|
|
124
|
+
* A provider send's destination as its list compares it, and as it is sent:
|
|
125
|
+
* an SMS number normalised (`+1 (555) 000-1111` is `+15550001111`), a push
|
|
126
|
+
* target trimmed. Sending the call's own spelling would let a gateway read
|
|
127
|
+
* it as another number than the one the list and a permission rule decided on.
|
|
128
|
+
*/
|
|
129
|
+
export declare function destinationFor(to: string, kind: DestinationKind): string;
|
|
130
|
+
/**
|
|
131
|
+
* Why a provider send's destination is refused, or `null` when the list
|
|
132
|
+
* admits it. The one matcher for both lists: an entry matches exactly, or,
|
|
133
|
+
* ending in `*`, as a prefix. An SMS number is compared in normalised E.164
|
|
134
|
+
* form, so a destination that is not a number in that form matches nothing.
|
|
135
|
+
*/
|
|
136
|
+
export declare function destinationRefusal(to: string, kind: DestinationKind, cfg: NotifyConfig): string | null;
|
|
87
137
|
/** Replace the process-global allow-list. Codegen calls this at boot. */
|
|
88
138
|
export declare function registerNotifyConfig(input: NotifyConfigInput): void;
|
|
89
139
|
export declare function getNotifyConfig(): NotifyConfig;
|
|
@@ -240,11 +290,38 @@ export declare function isPrivateIp(address: string): boolean;
|
|
|
240
290
|
* this package's older name and signature lives here instead.
|
|
241
291
|
*/
|
|
242
292
|
export declare function expandIpv6(raw: string): number[] | null;
|
|
293
|
+
/**
|
|
294
|
+
* The reason a deadline's own timer aborts its signal with. An error is
|
|
295
|
+
* traced to the deadline by this reason, never by the clock: a transport
|
|
296
|
+
* failure that merely ARRIVES after the deadline's time (a starved event
|
|
297
|
+
* loop delivers it before the timer callback runs) is that failure, and a
|
|
298
|
+
* runtime cancel aborts with the runtime's reason instead.
|
|
299
|
+
*/
|
|
300
|
+
export declare class DeadlineElapsedError extends Error {
|
|
301
|
+
readonly name = "TimeoutError";
|
|
302
|
+
}
|
|
243
303
|
export type Deadline = {
|
|
244
304
|
readonly signal: AbortSignal;
|
|
245
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* Milliseconds left; never negative, and 0 once the signal has aborted —
|
|
307
|
+
* by the timer, an outer deadline or a cancel — whatever the clock says.
|
|
308
|
+
* Ask it before starting more work: 0 means start nothing.
|
|
309
|
+
*/
|
|
246
310
|
remaining(): number;
|
|
311
|
+
/**
|
|
312
|
+
* The clock says the time is up. The clock can lag the timer and never
|
|
313
|
+
* sees a cancel, so it decides neither whether to start more work — that
|
|
314
|
+
* is {@link remaining} — nor why something failed — that is
|
|
315
|
+
* {@link timedOut}. It only notes, beside a failure of its own, that the
|
|
316
|
+
* time had run out as well.
|
|
317
|
+
*/
|
|
247
318
|
expired(): boolean;
|
|
319
|
+
/**
|
|
320
|
+
* The deadline's timer — or an outer deadline's, forwarded — really
|
|
321
|
+
* aborted the signal. False for a runtime cancel, and false while the
|
|
322
|
+
* timer has not run, however late the clock says it is.
|
|
323
|
+
*/
|
|
324
|
+
timedOut(): boolean;
|
|
248
325
|
/** Clear the timer. Always call it, or the process keeps a handle alive. */
|
|
249
326
|
cancel(): void;
|
|
250
327
|
};
|
|
@@ -279,15 +356,20 @@ export type ResolvedSecret = {
|
|
|
279
356
|
readonly message: string;
|
|
280
357
|
};
|
|
281
358
|
/**
|
|
282
|
-
* Read a secret out of the named environment variable
|
|
359
|
+
* Read a secret out of the named environment variable, if and only if
|
|
360
|
+
* `allowed` lists it.
|
|
283
361
|
*
|
|
284
|
-
* The NAME travels through the tool call; the value never does.
|
|
285
|
-
* is
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
362
|
+
* The NAME travels through the tool call; the value never does. `allowed`
|
|
363
|
+
* is the operator's `allowed_secret_envs` for a name a call supplies, or a
|
|
364
|
+
* provider's own `auth.envVar` for that provider's call: a call may choose
|
|
365
|
+
* among the listed names and never add one, so it cannot send another
|
|
366
|
+
* process secret (the LLM provider's key, say) to an allowed origin, where
|
|
367
|
+
* the egress classifier would see only the name. The refusal names the key
|
|
368
|
+
* to set, reads the same whether or not an unlisted variable is set, and
|
|
369
|
+
* never quotes a value or a "name" that is really a pasted secret, because a
|
|
370
|
+
* tool result is a transcript, a trace and usually an eval report.
|
|
289
371
|
*/
|
|
290
|
-
export declare function resolveSecret(envVar: string | undefined, what: string, env?: Record<string, string | undefined>): ResolvedSecret;
|
|
372
|
+
export declare function resolveSecret(envVar: string | undefined, what: string, allowed: readonly string[], env?: Record<string, string | undefined>): ResolvedSecret;
|
|
291
373
|
/** Header names that must never be supplied inline, and never survive a hop. */
|
|
292
374
|
export declare const CREDENTIAL_HEADERS: ReadonlySet<string>;
|
|
293
375
|
/**
|
|
@@ -299,7 +381,7 @@ export declare function rejectInlineCredentials(headers: Record<string, string>)
|
|
|
299
381
|
export type AppliedAuth = {
|
|
300
382
|
readonly ok: true;
|
|
301
383
|
readonly secretHeaders: ReadonlySet<string>;
|
|
302
|
-
readonly secrets:
|
|
384
|
+
readonly secrets: SecretValue[];
|
|
303
385
|
} | {
|
|
304
386
|
readonly ok: false;
|
|
305
387
|
readonly message: string;
|
|
@@ -310,7 +392,7 @@ export type AppliedAuth = {
|
|
|
310
392
|
* guarded exactly as hard as one that puts it in `Authorization` — and the
|
|
311
393
|
* resolved secret values, which feed the redactor.
|
|
312
394
|
*/
|
|
313
|
-
export declare function applyAuth(headers: Record<string, string>, auth: AuthProfile | undefined, env?: Record<string, string | undefined>): AppliedAuth;
|
|
395
|
+
export declare function applyAuth(headers: Record<string, string>, auth: AuthProfile | undefined, allowed: readonly string[], env?: Record<string, string | undefined>): AppliedAuth;
|
|
314
396
|
/**
|
|
315
397
|
* A function that scrubs secrets out of anything on its way back to the
|
|
316
398
|
* caller.
|
|
@@ -325,8 +407,26 @@ export declare function applyAuth(headers: Record<string, string>, auth: AuthPro
|
|
|
325
407
|
*
|
|
326
408
|
* Secrets shorter than six characters are left alone: replacing every "x" in
|
|
327
409
|
* a result would mangle it without protecting anything real.
|
|
410
|
+
*
|
|
411
|
+
* This is tool-safety's redactor: every spelling of a secret, and the part
|
|
412
|
+
* of one a cut left at a string's end. A cut this package makes itself is
|
|
413
|
+
* trimmed where it is made ({@link readCapped}, {@link excerptOf}), because a
|
|
414
|
+
* cut inside a JSON result is not at the end of the text this runs on (net
|
|
415
|
+
* attacker review: a byte cap through an echoed header returned all but one
|
|
416
|
+
* character of the credential).
|
|
417
|
+
*
|
|
418
|
+
* `identifiers` (an SMTP username) are replaced only whole, as 0.7.0 did:
|
|
419
|
+
* they are not secrets, so the start of one at a string's end is not cut.
|
|
420
|
+
*/
|
|
421
|
+
export declare function redactorFor(secrets: readonly (SecretValue | undefined)[], identifiers?: readonly (string | undefined)[]): (text: string) => string;
|
|
422
|
+
/** The most of a reply an error message quotes. */
|
|
423
|
+
export declare const EXCERPT_CHARS = 500;
|
|
424
|
+
/**
|
|
425
|
+
* The first {@link EXCERPT_CHARS} of a reply, for an error message. When
|
|
426
|
+
* that cuts the reply, whatever part of an echoed credential the cut leaves
|
|
427
|
+
* at the end is trimmed, since the redactor matches whole spellings.
|
|
328
428
|
*/
|
|
329
|
-
export declare function
|
|
429
|
+
export declare function excerptOf(text: string, secrets: readonly (SecretValue | undefined)[]): string;
|
|
330
430
|
export declare function responseHeaders(res: Response): Record<string, string>;
|
|
331
431
|
export type RawFetch = (req: Request, pinnedIp: string) => Promise<Response>;
|
|
332
432
|
export declare function _setRawFetch(fn: RawFetch | undefined): void;
|
|
@@ -366,13 +466,48 @@ export type CappedBody = {
|
|
|
366
466
|
readonly truncated: boolean;
|
|
367
467
|
};
|
|
368
468
|
/**
|
|
369
|
-
* Drain a body with a hard byte cap
|
|
370
|
-
*
|
|
371
|
-
*
|
|
469
|
+
* Drain a body with a hard byte cap on its DECODED size, so a hostile
|
|
470
|
+
* endpoint cannot pin memory: the body arrives raw (see `pinnedFetch`), a
|
|
471
|
+
* gzip, deflate, br or zstd body is decoded here in small steps, and the
|
|
472
|
+
* decoder stops once `maxBytes` exist. The cap bounds what is ever held, not
|
|
473
|
+
* what is kept after buffering.
|
|
474
|
+
*
|
|
475
|
+
* A body this reader cannot decode within the bound (an unknown or stacked
|
|
476
|
+
* coding, a corrupt stream) is a refusal that quotes nothing the endpoint
|
|
477
|
+
* sent. An aborted read throws an `AbortError`, so `describeFailure` reports
|
|
478
|
+
* the deadline or the abort as it does for the request itself.
|
|
372
479
|
*/
|
|
373
|
-
export declare function readCapped(res: Response, maxBytes: number): Promise<CappedBody>;
|
|
480
|
+
export declare function readCapped(res: Response, maxBytes: number, signal?: AbortSignal, secrets?: readonly (SecretValue | undefined)[]): Promise<CappedBody>;
|
|
374
481
|
/** A URL the caller supplied, or a readable refusal that does not echo it. */
|
|
375
482
|
export declare function parseUrl(raw: string): URL | string;
|
|
483
|
+
/**
|
|
484
|
+
* True for a value made only of dots. `.` and `..` are the two values
|
|
485
|
+
* `encodeURIComponent` does NOT neutralise: they encode to themselves, and
|
|
486
|
+
* the URL parser then RESOLVES them, so a message id of `..` substituted
|
|
487
|
+
* into `/channels/1/messages/{id}` addresses `/channels/1/` — the channel,
|
|
488
|
+
* not a message. A dot segment is never an id.
|
|
489
|
+
*/
|
|
490
|
+
export declare function isDotSegment(value: string): boolean;
|
|
491
|
+
/**
|
|
492
|
+
* `template` with every `placeholder` replaced by `value`, percent-encoded,
|
|
493
|
+
* as the URL that will be requested — or why not: `invalid` when the
|
|
494
|
+
* template is not an absolute URL, `reshaped` when the parser would send
|
|
495
|
+
* the request anywhere but the template's own path with the value written
|
|
496
|
+
* in.
|
|
497
|
+
*
|
|
498
|
+
* The check is made on the URL the parser produced, not on the string it
|
|
499
|
+
* was built from: the template is also parsed with a sentinel in the
|
|
500
|
+
* value's place, and the two paths must match once the sentinel is the
|
|
501
|
+
* value. Whatever normalisation the parser applies (a dot segment today),
|
|
502
|
+
* a value that changes the shape of the path is refused.
|
|
503
|
+
*/
|
|
504
|
+
export declare function substituteIntoUrl(template: string, placeholder: string, value: string): {
|
|
505
|
+
readonly ok: true;
|
|
506
|
+
readonly url: URL;
|
|
507
|
+
} | {
|
|
508
|
+
readonly ok: false;
|
|
509
|
+
readonly why: "invalid" | "reshaped";
|
|
510
|
+
};
|
|
376
511
|
/** Read a dotted path out of a parsed JSON body. `undefined` when absent. */
|
|
377
512
|
export declare function readPath(value: unknown, path: string): unknown;
|
|
378
513
|
/**
|
|
@@ -381,11 +516,48 @@ export declare function readPath(value: unknown, path: string): unknown;
|
|
|
381
516
|
* because the caller's next move differs.
|
|
382
517
|
*/
|
|
383
518
|
export declare function describeFailure(err: unknown, deadline?: Deadline): string;
|
|
519
|
+
/**
|
|
520
|
+
* Whether `err` is what the deadline's own timer did: its abort reason, or an
|
|
521
|
+
* abort-shaped error (a body reader's AbortError) raised after that timer
|
|
522
|
+
* fired. Exported for the few callers that report a deadline themselves.
|
|
523
|
+
*/
|
|
524
|
+
export declare function isDeadlineAbort(err: unknown, deadline?: Deadline): boolean;
|
|
525
|
+
/**
|
|
526
|
+
* What makes two calls under one key the SAME request: every argument except
|
|
527
|
+
* the ones that only say how to deliver it (the key itself, deadlines, byte
|
|
528
|
+
* caps, retry pacing), each reduced to a SHA-256 of its canonical JSON. Kept
|
|
529
|
+
* per field, so a refusal can say which fields differed without keeping
|
|
530
|
+
* the message itself in memory. A caller-supplied clock reading that only
|
|
531
|
+
* stamps the message (EmailSend's `date`, a webhook signature's timestamp)
|
|
532
|
+
* is left out by the caller: a retry that re-reads its clock is still a
|
|
533
|
+
* retry.
|
|
534
|
+
*/
|
|
535
|
+
export type RequestFingerprint = ReadonlyMap<string, string>;
|
|
536
|
+
export declare function requestFingerprint(args: Readonly<Record<string, unknown>>): RequestFingerprint;
|
|
384
537
|
export type LedgerEntry = {
|
|
385
538
|
readonly tool: string;
|
|
539
|
+
readonly fingerprint: RequestFingerprint;
|
|
386
540
|
readonly result: string;
|
|
387
541
|
};
|
|
388
|
-
|
|
389
|
-
|
|
542
|
+
/**
|
|
543
|
+
* `replay` — this key already sent this request; here is what it returned.
|
|
544
|
+
* `conflict` — this key already sent a DIFFERENT request, which this call
|
|
545
|
+
* must not be mistaken for: it is refused and sends nothing.
|
|
546
|
+
*/
|
|
547
|
+
export type LedgerAnswer = {
|
|
548
|
+
readonly kind: "replay";
|
|
549
|
+
readonly result: string;
|
|
550
|
+
} | {
|
|
551
|
+
readonly kind: "conflict";
|
|
552
|
+
readonly differs: readonly string[];
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* `ignoring` names fields left out of the comparison: EmailSend asks with
|
|
556
|
+
* `attachmentContent` ignored when an attachment can no longer be read, so
|
|
557
|
+
* a retry after a temp file was cleaned up finds the message it already
|
|
558
|
+
* sent instead of reporting it unsent.
|
|
559
|
+
*/
|
|
560
|
+
export declare function ledgerLookup(tool: string, key: string | undefined, fingerprint: RequestFingerprint, ignoring?: readonly string[]): LedgerAnswer | undefined;
|
|
561
|
+
export declare function ledgerRecord(tool: string, key: string | undefined, fingerprint: RequestFingerprint, result: string): void;
|
|
390
562
|
/** Test-only — a fresh process's empty ledger. */
|
|
391
563
|
export declare function _resetIdempotencyLedger(): void;
|