@crewhaus/tool-notify 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
- /** Milliseconds left; never negative. */
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. A value that
285
- * is not shaped like an environment variable name, or that carries a known
286
- * secret prefix, is refused as a pasted credential — and the refusal quotes
287
- * nothing, because a tool result is a transcript, a trace and usually an
288
- * eval report.
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: string[];
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 redactorFor(secrets: readonly (string | undefined)[]): (text: string) => string;
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, cancelling the stream the moment the cap
370
- * is passed so a hostile endpoint cannot pin memory. The cap bounds what is
371
- * ever held, not what is kept after buffering.
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
- export declare function ledgerLookup(tool: string, key: string | undefined): LedgerEntry | undefined;
389
- export declare function ledgerRecord(tool: string, key: string | undefined, result: string): void;
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;