@crewhaus/tool-notify 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # @crewhaus/tool-notify
2
+
3
+ Messaging and notification tools: post to a chat platform, send email over
4
+ SMTP, deliver a signed webhook, reach a phone — and, first, decide whether to
5
+ send anything at all.
6
+
7
+ `@crewhaus/tool-message-channel`'s `SendMessage` replies on the harness's own
8
+ bound channel: the room it was spoken to in. That is the right primitive for a
9
+ conversation, and it is not the one a scheduled job needs when the thing it has
10
+ to say belongs in `#ops`, or in an email to the person who owns the broken
11
+ service. These tools address a destination an **operator** allow-listed, which
12
+ is a different and more dangerous act.
13
+
14
+ ```yaml
15
+ tools:
16
+ - all-notify # every tool below
17
+ - -SmsSend # ...except this one
18
+ ```
19
+
20
+ | Tool | What it does |
21
+ |---|---|
22
+ | `ChatPost` | Post to Slack, Discord, Teams or a generic webhook, with optional blocks and a thread id |
23
+ | `ChatUpdate` | Edit a message already posted, by its id |
24
+ | `ChatDelete` | Remove a message already posted |
25
+ | `ChatReact` | Add an emoji reaction |
26
+ | `EmailSend` | Send over SMTP: EHLO, STARTTLS, AUTH, MAIL FROM, RCPT TO, DATA |
27
+ | `EmailCompose` | Build the RFC 5322 bytes without sending them |
28
+ | `EmailSendPreflight` | Check a message the way `EmailSend` would build it, and send nothing |
29
+ | `DeliverabilityCheck` | Read a sending domain's SPF, DMARC and named DKIM selectors out of public DNS |
30
+ | `WebhookPost` | POST JSON to an allow-listed URL, optionally HMAC-signed, retrying 5xx |
31
+ | `SmsSend` | SMS through a REST gateway the operator described |
32
+ | `PushNotify` | Push notification through a REST provider the operator described |
33
+ | `DeliveryCheck` | Ask a provider what became of a message it accepted |
34
+ | `NotifyDigest` | Fold many events into one message |
35
+ | `QuietHours` | May this go out now, and if not, when? |
36
+ | `RateLimitGate` | Has this key already been notified inside the window? |
37
+ | `MessageTemplate` | Render a named template with per-platform escaping |
38
+
39
+ ## Start with the three that stop you sending
40
+
41
+ The tools worth reaching for first are the ones that send nothing.
42
+
43
+ `NotifyDigest` is the reason a watcher does not page a channel forty times
44
+ about the same broken host. `QuietHours` is the reason an overnight alert waits
45
+ until 09:00 when it will read identically. `RateLimitGate` is the reason a loop
46
+ does not re-report the same thing on every iteration. All three are pure, take
47
+ `now` as an argument, and compose:
48
+
49
+ ```
50
+ NotifyDigest → QuietHours → RateLimitGate → MessageTemplate → ChatPost
51
+ ```
52
+
53
+ ## Safety flags
54
+
55
+ Every tool that sends is `destructive: true` **and**
56
+ `requireJustification: true`. Putting text in front of a person is a side
57
+ effect that deleting the message does not undo, because it has already been
58
+ read — that is precisely what the intent gate is for, and nothing here opts
59
+ out of it. `DeliveryCheck` and `DeliverabilityCheck` are the outbound tools that only
60
+ read, so they are `readOnly` and carry no justification. The six pure tools
61
+ are `readOnly`, `scope: "internal"`, declare no io capability, and are
62
+ concurrency-safe. `src/index.test.ts` asserts all of this per tool, so a
63
+ future addition that forgets cannot land quietly.
64
+
65
+ The two `…Check` names sit uncomfortably close, so: `DeliveryCheck` asks a
66
+ PROVIDER what became of one message it accepted. `DeliverabilityCheck` asks
67
+ public DNS what a DOMAIN publishes about itself, and knows nothing about any
68
+ particular message.
69
+
70
+ ## The outbound posture
71
+
72
+ `src/net.ts` carries `@crewhaus/tool-http`'s gate over whole rather than
73
+ re-deriving it — a second outbound surface with a weaker gate is the same hole
74
+ twice. In summary:
75
+
76
+ - **Empty allow-list denies everything.** Separately for origins, for SMTP
77
+ hosts, and for email recipients. There is no "allow all" value.
78
+ - **SSRF refusal is numeric, not textual.** Loopback, link-local (including
79
+ the cloud metadata address), RFC1918, CGNAT, multicast and mDNS are refused
80
+ as an IP literal in any of its encodings — octal, hex, integer, every IPv6
81
+ spelling — and as the DNS-resolved address. An IPv6-shaped string the parser
82
+ cannot classify is refused, never waved through.
83
+ - **The vetted IP is pinned** for the connection, so a rebinding resolver
84
+ cannot swap in a private address between the check and the socket. The SMTP
85
+ client pins the same way, and still presents and verifies the hostname.
86
+ - **A send never follows a redirect.** Replaying a body at a new origin, or
87
+ downgrading to a GET that delivers nothing, are both worse than stopping.
88
+ Only `DeliveryCheck` follows, re-running the whole gate on every hop.
89
+ - **Everything is deadline-bounded and byte-capped,** with the cap bounding
90
+ what is ever held in memory rather than applied after buffering.
91
+
92
+ ## Credentials
93
+
94
+ No tool accepts a secret. Every credential is the **NAME** of an environment
95
+ variable, read at call time:
96
+
97
+ ```yaml
98
+ tool_config:
99
+ notify:
100
+ allowed_origins: ["https://hooks.slack.com", "https://slack.com"]
101
+ allowed_recipients: ["ops@example.com", "*@team.example.com"]
102
+ allowed_smtp_hosts: ["smtp.example.com"]
103
+ allowed_sender_domains: ["example.com"]
104
+ ```
105
+
106
+ ```jsonc
107
+ { "platform": "slack", "webhookUrlEnv": "SLACK_OPS_WEBHOOK", "text": "…" }
108
+ ```
109
+
110
+ A Slack incoming-webhook URL (`/services/T…/B…/…`) and a Discord one
111
+ (`/api/webhooks/<id>/<token>`) carry their whole authority in the **path**, so
112
+ the URL is itself a credential: it is named rather than passed, and no error
113
+ message ever prints a webhook path. `WebhookPost` refuses one handed to `url`
114
+ inline — where it would be recorded verbatim — and points at `urlEnv`. A value that arrives where a variable name
115
+ was expected and looks like a secret is refused **without being echoed back**,
116
+ because a tool result is a transcript, a trace and usually an eval report. Every
117
+ result — success or failure — passes through a redactor built from the resolved
118
+ secret, in its literal, URL-encoded and base64 forms.
119
+
120
+ ## Injection is the thing this package is most careful about
121
+
122
+ A notification is almost always assembled from data the harness did not write:
123
+ a branch name, an error string, a customer's subject line.
124
+
125
+ - **Chat.** Every value is escaped for its platform before it is sent, so
126
+ `<!channel>`, `@everyone` or a stray code fence can change what a message
127
+ *says* and never what it *does*. Discord is additionally sent
128
+ `allowed_mentions: { parse: [] }`, which is the only reliable way to stop a
129
+ mention in a body from paging a server. A link block whose URL is not
130
+ http(s) is dropped and reported, never rendered.
131
+ - **Templates.** `MessageTemplate` escapes the **values** and leaves the
132
+ **template** alone: the operator owns the structure, the run supplies the
133
+ content, and there is no path by which content becomes structure. A missing
134
+ key is an error rather than an empty string, because a notification reading
135
+ `deploy of failed at ` still gets acted on.
136
+ - **Email.** Any header value that is not plain printable ASCII becomes an RFC
137
+ 2047 encoded word — which turns a CR or LF into `=0D`/`=0A` inside the word
138
+ — and an address containing either is refused outright. A subject cannot add
139
+ a `Bcc:`. The values that are emitted verbatim and so cannot be encoded — a
140
+ message id in `In-Reply-To` or `References`, an attachment's `Content-ID` —
141
+ are checked against their grammar instead, and the finished header block is
142
+ re-checked for a line break that is not a fold. Blind recipients reach the
143
+ envelope and never a header.
144
+ - **SMTP.** SMTP is a line protocol, so a CR or LF inside any command argument
145
+ is not a malformed command but a second one. The EHLO name and the envelope
146
+ addresses are checked before the socket opens, which is what stops a
147
+ smuggled `RCPT TO:` from reaching a recipient the allow-list never saw.
148
+
149
+ ## Email, specifically
150
+
151
+ `EmailCompose` builds the message; `EmailSend` sends it. Splitting them means
152
+ the assembly is testable without a mail server and the bytes can be handed to
153
+ another transport.
154
+
155
+ The composer does RFC 2047 encoded words, header folding that never breaks an
156
+ encoded word or an address in half, quoted-printable bodies with correct
157
+ trailing-whitespace and soft-break handling, base64 attachments, and
158
+ `multipart/alternative` inside `multipart/mixed`. MIME boundaries are derived
159
+ from a hash of the message's own content rather than from a random number, so
160
+ composing the same message twice gives byte-identical output.
161
+
162
+ The SMTP client is written out over `node:net` / `node:tls`. **STARTTLS is
163
+ required by default**: a server that does not offer it ends the session before
164
+ the credential, the envelope or the body is sent. The transcript records
165
+ `AUTH PLAIN <redacted>` and the base64 blob is never kept. Attachment size is
166
+ checked from the file's own metadata *before* it is read — and counted as the
167
+ base64 it will become, not as the bytes on disk — so an oversized attachment
168
+ never reaches memory.
169
+
170
+ ## Before the send
171
+
172
+ `EmailSendPreflight` takes the same arguments as `EmailSend` and runs the
173
+ same composer, and sends nothing. It exists because `EmailSend` answers one
174
+ refusal at a time, at the moment somebody has already decided to send, and
175
+ because some of what is wrong with a message is not a refusal at all: an
176
+ empty body composes perfectly and delivers nothing, a `{{placeholder}}` that
177
+ never got filled goes out as written, the same mailbox on To and Bcc is one
178
+ copy rather than two. The result is a row per check, and the verdict has
179
+ three values rather than two — a check that could not run comes back
180
+ `unknown`, and a message with an attachment nobody could read is
181
+ `incomplete`, which is neither ready nor blocked.
182
+
183
+ `DeliverabilityCheck` reads what a sending domain publishes: its SPF record,
184
+ its DMARC policy, and the DKIM key record at each selector the caller names.
185
+ It reports facts rather than a score, because the facts decide different
186
+ things — no DMARC record at all and a DMARC record with `p=none` score the
187
+ same and are not the same situation, and a lookup that failed is a third
188
+ answer again, reported as `unknown` with its reason. There is no way to
189
+ enumerate DKIM selectors, so it checks the ones you name and guesses none.
190
+
191
+ DNS is not an HTTP request and so does not pass through `allowed_origins`,
192
+ but the name queried is still chosen by the caller and still leaves the
193
+ machine. `allowed_sender_domains` is that surface's allow-list, fail-closed
194
+ like the others: empty denies everything. The `_dmarc.` and `._domainkey.`
195
+ names are derived from the canonicalised domain after it passes, so there is
196
+ no spelling that clears the gate and a different one that reaches the
197
+ resolver.
198
+
199
+ ## What is deliberately not here
200
+
201
+ - **`SendMessage`.** That is `@crewhaus/tool-message-channel`'s, for the
202
+ harness's own bound channel. This package does not duplicate it.
203
+ - **A mail queue, retries or bounce handling.** `EmailSend` sends once and
204
+ reports what the server said. A refused recipient comes back named; deciding
205
+ whether to try again is the caller's.
206
+ - **CRAM-MD5, XOAUTH2, DSN, pipelining, connection reuse.** PLAIN and LOGIN
207
+ over TLS cover submission to every service that matters; the rest would be
208
+ surface without users.
209
+ - **DKIM signature verification.** `DeliverabilityCheck` reads the KEY
210
+ record a selector publishes — its type, its size, whether it has been
211
+ revoked. It does not verify that a message's signature validates: that
212
+ needs relaxed/simple canonicalisation, header selection and body hashing,
213
+ which is `mailauth`'s job and a dependency this package does not take. A
214
+ half-built verifier that can answer "pass" is a security control that
215
+ controls nothing, so there is no partial one here.
216
+ - **Walking SPF includes.** `dnsTermsInThisRecord` counts the DNS-querying
217
+ terms in the record it read, and says so. The RFC's limit of ten counts
218
+ the whole evaluation, with its own sub-limits for `a` and `mx` and a
219
+ separate void-lookup cap; a walker that got those wrong would report a
220
+ confident total that is not the receiver's. It would also mean querying
221
+ domains the caller never named.
222
+ - **Reading messages.** Nothing here receives; it only sends.
223
+ - **Vendor SDKs.** `SmsSend`, `PushNotify` and `DeliveryCheck` speak a provider
224
+ shape the operator writes in `tool_config`. Changing gateway is a config
225
+ diff, not a release.
226
+
227
+ ## Known gaps
228
+
229
+ Stated rather than papered over:
230
+
231
+ - **A successful STARTTLS upgrade is not covered by a test.** The refusals
232
+ are: a server offering no STARTTLS, a handshake that cannot complete, and an
233
+ untrusted certificate all end the session with the password unsent and the
234
+ message undelivered. But building a STARTTLS *server* needs an
235
+ already-connected plaintext socket handed to a TLS server, and this
236
+ runtime's `node:tls` shim does not support that — such a test would hang
237
+ rather than assert. See `src/smtp.test.ts`.
238
+ - **Implicit TLS (port 465) is untested** for the same reason.
239
+ - **The idempotency ledger is per-process.** A retry after a restart will send
240
+ again. The ledger is the second line of defence; the key is also passed to
241
+ the provider, which is the first.
242
+ - **`ChatUpdate` does not return the previous text,** because the platform
243
+ does not give it back.
244
+ - **`EmailSendPreflight` does not fold provider-specific aliases.** Two
245
+ recipients count as duplicates on their exact spelling, case aside. Gmail
246
+ treats `a.b@` and `ab@` as one mailbox and strips `+tags`; most providers
247
+ do neither, so folding them here would report two separate people as one
248
+ on every domain that keeps them apart. Which domains fold is configuration
249
+ this package does not carry. Two spellings of one mailbox that differ only
250
+ in case are reported as a **warning that they will each get their own
251
+ `RCPT TO`** — `composeMessage` builds its envelope from the exact
252
+ addresses, so that is what the send does, and the preflight reports the
253
+ composer's count rather than its own comparison key's.
254
+ - **`DeliverabilityCheck` does not walk up to the organizational domain.**
255
+ RFC 7489 6.6.3 has a receiver that finds no `_dmarc` record fall back to
256
+ the organizational domain's, so a subdomain with nothing of its own can
257
+ still be covered. Naming that domain needs the Public Suffix List —
258
+ `example.co.uk` is an organizational domain and `mail.example.com` is not,
259
+ and nothing in the name says which — which is a dependency this package
260
+ does not take. So a domain with more than two labels and no record of its
261
+ own gets a note saying which lookup was not made, and the answer stops at
262
+ the name that was asked.
263
+
264
+ ## Layout
265
+
266
+ `src/lib/` holds the pure functions — escaping and block rendering, templates,
267
+ digests, quiet hours, rate limiting, MIME, the preflight rules and the SPF,
268
+ DMARC and DKIM record parsers — and is where the behaviour is tested. `src/net.ts` is the outbound gate, `src/paths.ts` the path gate,
269
+ `src/smtp.ts` the SMTP client, and `src/index.ts` wraps all of it as tools. A
270
+ bug in quoted-printable encoding reads better as a failing unit than as a
271
+ failing tool call.
@@ -0,0 +1,21 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ export { NotifyPermissionError, _resetIdempotencyLedger, _resetNotifyConfig, _setDnsLookup, _setDnsTxtResolver, _setRawFetch, __setPrivateHostsAllowedForTest, buildNotifyConfig, canonicalizeOrigin, getNotifyConfig, registerNotifyConfig, } from "./net";
3
+ export { ToolPermissionError } from "./paths";
4
+ export declare const chatPost: RegisteredTool;
5
+ export declare const chatUpdate: RegisteredTool;
6
+ export declare const chatDelete: RegisteredTool;
7
+ export declare const chatReact: RegisteredTool;
8
+ export declare const emailCompose: RegisteredTool;
9
+ export declare const emailSend: RegisteredTool;
10
+ export declare const emailSendPreflight: RegisteredTool;
11
+ export declare const webhookPost: RegisteredTool;
12
+ export declare const smsSend: RegisteredTool;
13
+ export declare const pushNotify: RegisteredTool;
14
+ export declare const deliveryCheck: RegisteredTool;
15
+ export declare const deliverabilityCheck: RegisteredTool;
16
+ export declare const notifyDigest: RegisteredTool;
17
+ export declare const quietHours: RegisteredTool;
18
+ export declare const rateLimitGate: RegisteredTool;
19
+ export declare const messageTemplate: RegisteredTool;
20
+ /** Every tool this package registers, in the order a catalog should list them. */
21
+ export declare const NOTIFY_TOOLS: ReadonlyArray<RegisteredTool>;