Skip to content

Notify tasks: merge main, real-transport tests, Slack structural broadcasts, webhook redaction - #744

Draft
sroussey wants to merge 34 commits into
mainfrom
claude/notify-merge-main
Draft

Notify tasks: merge main, real-transport tests, Slack structural broadcasts, webhook redaction#744
sroussey wants to merge 34 commits into
mainfrom
claude/notify-merge-main

Conversation

@sroussey

@sroussey sroussey commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #678, and consolidates #745 and #747 (both now closed — their content ships here). Four pieces, each with its own rationale below.

origin/main is merged in as of 67bed681.


1. Merge main — picks up the unhandled-rejection fix

What

The webhook notification tasks cancel their success body unread — postWebhookJson passes readSuccessBody: false for both Slack and Discord, and Slack answers 200 with a short ok body every single time. On the server transport, cancelling the returned readable errors the writable behind it, so body.pipeTo(writable) rejects with the cancel reason (undefined for a bare cancel()). #678's branch has no .catch on that promise, so under Node's default --unhandled-rejections=throw every successful Slack post terminates the process.

That fix already landed on main in 241c810d, along with SafeFetchServerTransport.test.ts. #678 predated it (17 ahead / 105 behind at the time). So this branch does not re-patch SafeFetch.server.ts — it merges main and adds the coverage main's file does not have.

Why merge, not rebase

#678's tip is itself a merge commit (Merge pull request #736); replaying 14 commits buys nothing and rewrites shared history. This was re-tested when the branch was brought up to current main: git rebase origin/main re-hits, on commit 1 of 14, the very packages/tasks/src/common.ts conflict the merge had already resolved, and would do so again for each subsequent commit that touches the same files — resolving each against an intermediate tree that never existed. The rebase was aborted and a merge used instead.

The first merge produced exactly two conflicts, both trivial adjacent insertions into the same line of an alphabetised list, both resolved by keeping both sides:

  • packages/tasks/src/common.ts — branch's DiscordNotifyTask export vs main's FetchUrlCredentials export.
  • packages/test/src/test/task/FetchUrlSsrf.test.ts — branch's node: builtin imports vs main's getTestingLogger import.

SafeFetch.server.ts merged clean, and the merged file was verified to carry .catch(() => {}) with packages/tasks/src/node.ts still side-effect-importing it. The second merge (to 67bed681) was conflict-free.


2. Real-transport tests for the notify tasks

What / why

Every case in NotifyTask.test.ts runs against a process-global safeFetch mock installed via registerSafeFetch in beforeAll. So none of them exercise the undici request, the passthrough TransformStream, or the dispatcher lifecycle — a mocked Response has no pipe behind it and can never see the rejection above.

New packages/test/src/test/task/NotifyTaskTransport.test.ts talks to a throwaway node:http server on 127.0.0.1:0 and never mocks the transport. It is a separate file, not a block inside NotifyTask.test.ts: a real-transport block sharing that file's global mock would silently depend on vitest describe-ordering not to poison it. It carries the getSafeFetchImpl().name === "serverSafeFetch" tripwire — without it the whole file is vacuous, since a leaked mock would make every case pass against nothing.

case pre-fix
tripwire: registered impl is serverSafeFetch passes (guard, not a repro)
Slack 200 + ok body → {success:true,status:200}, no unhandled rejection — the production crash path fails
Slack 200, then waitForNoConnections === 0 — dispatcher released on the cancel path fails
Slack 400 with a >1 MB never-ended body → readBodyText cancels at SECURITY_LIMITS.webhookMaxResponseBodyBytes; asserts PermanentJobError, httpStatus: 400, no token in the message fails
Discord 204, no body → the non-TransformStream branch passes (regression guard)

The oversized-failure-body case is the second cancel site, which nothing anywhere exercises against the real transport, including main's new file.

Loopback is private address space, so every case sets allow_private_destination: true; no entitlement enforcer is registered, so assertPrivateDestinationGranted has no policy to satisfy — the arrangement existing tests already rely on.


3. Slack's structural Block Kit broadcasts (was #745)

What

SlackNotifyTask neutralized channel-wide broadcasts by escaping the literal <! in every string leaf of blocks, on the stated reasoning that <! is a broadcast sigil wherever Slack finds it and that walking to every leaf is therefore complete.

True of message text, false of rich_text. There, @channel is an element shape{"type": "broadcast", "range": "channel"} — with no <! anywhere in it, and a group ping is {"type": "usergroup", "usergroup_id": "S…"}. Both passed through untouched. A blocks payload assembled from a fetch result or a model completion could still notify an entire workspace with allow_mentions unset. Confirmed against the pre-fix code: the body went out as [{"type":"broadcast","range":"channel"}], verbatim.

The completeness claim was asserted in three places — the neutralizeSlackBroadcastsDeep JSDoc, the blocks port description, and packages/tasks/README.md — all wrong the same way.

Why this fix

A structural pass ahead of the key-copy loop rewrites a recognized element to a plain text node.

Rewrite rather than delete. Dropping the element can leave an elements[] empty, and Slack rejects that — turning a security control into an availability bug, a bad trade on a notification path. The replacement is literal text (@channel), and link_names: false is already sent whenever mentions are disallowed, so it cannot auto-link.

Match on type alone, not rich_text ancestry. The only false positive is a caller who deliberately wanted a live usergroup ping — exactly what the control is for. Requiring ancestry would instead miss any future context where Slack accepts the same element.

Residual, stated not papered over: unlike the lexical half this cannot be shape-agnostic — a structural broadcast is identified by nothing but its type name — so a new such type is uncovered until added to the set. All four docs now say so.

Tests (in mention neutering)

case pre-fix
{type:"broadcast",range:"channel"} → no "type":"broadcast", no "range":"channel", contains @channel fails
{type:"usergroup",usergroup_id:"S12345678"} → neither field survives, contains @usergroup fails
same broadcast with allow_mentions: true → still verbatim passes both sides
narrowness guard: {type:"section",…} untouched passes both sides

The two that pass on both sides are deliberate. The allow_mentions case currently passes for the wrong reason — nothing rewrites the element at all — so asserting it pins the gate rather than documenting it.


4. Webhook query-value and reason-phrase redaction (was #747)

What

For these tasks the webhook URL is the credential, and redactWebhookUrlIn keeps it out of diagnostics quoting the endpoint's reply. It reaches the caller because Slack and Discord both set includeBodyInError: true. Two shapes walked through it.

Query values were never candidates. Candidates came only from pathname+search, search, and path segments. An endpoint authenticating with ?token=SUPERSECRET1 that answers bad token SUPERSECRET1 matched nothing — the whole pair was a candidate, but the echo quotes the value alone. ?token= auth is an ordinary deployment shape.

All-lowercase candidates were exempted outright, on the theory that such a run is a word rather than a token. So /hooks/supersecrettoken echoed as rejected: supersecrettoken went out verbatim, despite being 16 characters and clearly a secret.

Rider: response.statusText was interpolated into the message and stored as httpStatusText with no redaction, and unlike the failure body was not withheld for a private destination — leaving the SSRF read the body suppression exists to prevent open through a narrower channel, since a server puts whatever it likes in a reason phrase.

Why this fix

Admission is now by length only. The services / webhooks exemption the lowercase rule was really protecting is expressed directly as a named set of the providers' routing segments — what it always meant, in terms that do not also exempt every lowercase secret.

Query values are admitted raw and decoded, and the raw query is split by hand rather than read from searchParams: that decoder turns + into a space, so a decoded value would not match the bytes an endpoint echoes back.

The 8-char floor is unchanged and now applies to query values too, so a short ?t=abc still leaks. Same policy segments already had; the JSDoc now says so rather than leaving it to be rediscovered.

Not folded in: the REDIRECT_NOT_FOLLOWED diagnostic (same file, different function). It needs three baked-in assertions flipped and is a diagnostics-quality issue, not a leak. Deliberately deferred.

Tests (in secret redaction / private destination failure bodies)

case pre-fix
?token=SUPERSECRET1 + 403 body echoing it → absent from .message and .stack fails
/hooks/supersecrettoken + 403 body echoing it → absent fails
/notifications/deploynotifications is redacted (the accepted cost, pinned) fails
public 403, reason phrase token SECRETTOKEN bad → absent from message and httpStatusText fails
private 400, reason phrase index=cluster-secrets shard=3 → message has 400, not cluster-secrets; httpStatusText undefined fails
structural guard: Discord 400 body invalid webhooks payloadwebhooks survives passes both sides

The last is the pre-existing over-redaction guard; its comment previously justified the exemption as "all-lowercase runs are words, not tokens" and now names the real reason, pinning the replacement for the deleted rule.


Consolidation

claude/notify-slack-structural-broadcast and claude/notify-redaction-query-and-status were merged into this branch at 9926f8ed. No conflict occurred — the two branches add to different describe blocks of NotifyTask.test.ts (mention neutering vs secret redaction) and touch disjoint source files (SlackNotifyTask.ts + README vs WebhookPost.ts), so ort auto-merged. Both sets were verified present afterwards by grep and by a full suite run.

Tests actually executed

bun install --frozen-lockfile + bun run use-source succeeded, so the suite runs.

  • Consolidated branch, whole packages/test/src/test/task/ directory: 67 files, 1059 passed, 24 skipped.
  • New transport file alone: 5 passed. Against the pre-merge SafeFetch.server.ts (temporarily restored from 46309a25): 3 failed / 2 passed, the three cancel-path cases each reporting [undefined] from the unhandledRejection collector.
  • Slack fix, against pre-fix source with tests kept: 2 failed / 91 passed.
  • Redaction fix, against pre-fix source with tests kept: 5 failed / 89 passed.
  • prettier --check clean on every changed file.

Risk / blast radius

Three behaviour changes a reviewer must accept:

  1. A caller who genuinely wants a live rich_text usergroup ping must now set allow_mentions: true.
  2. A generic webhook whose path carries a long lowercase word (/notifications/deploy) has that word redacted out of echoed diagnostics. Stated cost of dropping the exemption; pinned by its own test.
  3. httpStatusText is now undefined for a private destination, where it previously carried the reason phrase.

The merge pulls main into this PR, so CI re-runs in full. No pre-existing test asserted on statusText (verified by the full-directory run).

Reviewer note, not a work item: post-merge packages/tasks carries two credential idioms — main's FetchUrlCredentials.ts (public URL + separate bearer token) and this branch's resolveWebhookUrl (the URL is the secret). They do not collide. Unifying them is a separate conversation.

Unverified

  • Run under Node v22.22.2, not the Node 24 the repo asks for, and in use-source mode (dist re-export stubs), not against built bundles.
  • Whether Slack honours a caller-supplied rich_text broadcast element arriving through an incoming webhook was not confirmed live. Slack documents the element for chat.postMessage; incoming webhooks share the Block Kit payload format. The fix and the doc correction are justified either way — the docs asserted coverage that demonstrably did not exist.
  • tsc -p packages/tasks reports errors in this checkout, but the count is identical with and without the changes — unbuilt .d.ts artifacts of use-source mode, not a regression.
  • No fuzzing over URL shapes; redaction coverage is the cases above plus the pre-existing suite.
  • Whether any downstream consumer depends on httpStatusText being present for a private destination was not audited outside this repo.

sroussey and others added 20 commits August 6, 2026 18:42
Adds three side-effecting notification tasks to @workglow/tasks, following
the FetchUrlTask pattern: WebhookNotifyTask (generic JSON HTTP POST),
SlackNotifyTask (incoming webhook), and DiscordNotifyTask.

All three run inline through the SSRF-aware safeFetch wrapper and share a
single POST helper (util/WebhookPost.ts) covering typed error mapping,
Retry-After parsing, abort/timeout signals, and the private-network
entitlement plumbing. They declare cachePolicy { kind: "none" } since they
are side-effecting.

A Slack/Discord webhook URL is itself the credential — the token lives in
the URL path — so the URL is kept out of the output schema and every error
message, task output, and error field is redacted to the endpoint origin.

Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
… credential

resolveWebhookUrl prefers the resolved credential over the `url` port, but
webhookPrivateEntitlements classified `url` alone — so a public decoy URL
plus a credential holding an internal address graded as needing no
network:private, while postWebhookJson then self-granted allowPrivate from
the URL it actually used. A configured credential_key now forces the
fail-closed branch regardless of `url`.

Also cancel the unread success body on the 204/no-read path so Slack's
`ok` response does not hold a pooled connection open.

Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
…port

Move WebhookNotifyTask, SlackNotifyTask, and DiscordNotifyTask out of the
catch-all "Utility" category into a dedicated "Notification" category.

Rename their `credential_key` input port to `url_credential_key`. On these
tasks the resolved credential is the entire webhook URL — the secret itself,
since a Slack/Discord webhook token lives in the URL path — and it takes
precedence over the `url` port. FetchUrlTask's identically-named port instead
resolves to a bearer token layered onto a public URL, so sharing the name
`credential_key` across both was misleading. FetchUrlTask is unchanged.

The port keeps its `format: "credential"` and `x-ui-hidden` annotations, and
the fail-closed `network:private` entitlement behavior is unaffected: a
configured credential still forces the entitlement because the destination is
unknowable at evaluation time.

Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
…#714)

Two HIGH findings in the webhook/Slack/Discord notification tasks, plus
five hardening fixes.

Credential leak via `error.stack`. `BaseError` never overrides `stack`, so
V8 bakes the original message into it. `toRedactedWebhookError` rewrote
`.message` and `.url` but then copied `.stack` verbatim, re-importing the
full webhook URL — and stacks are persisted (`formatErrorChainForDiagnostics`
walks the cause chain into the stored job error), so "logs are trusted" was
never an available defence. `redactedStackFrom` now rebuilds the stack from
the rewritten header plus the original frames (split at the first `    at `
frame, not the first line, since a message may contain newlines) and runs
the whole thing through a second redaction pass. It fails closed to a
header-only stack on runtimes with no `    at ` frames. `error.cause` is
deliberately not copied.

Mention injection. `content`/`text` was forwarded verbatim with no mention
controls, and `additionalProperties: false` meant a caller could not supply
them either — so piping a fetch result or a model summary into a
notification pinged a whole server on every run. Discord now defaults to
`allowed_mentions: { parse: [] }`. Slack has no equivalent, so the literal
`<!` is escaped to `&lt;!` (defusing `<!channel>`, `<!here>`, `<!everyone>`,
`<!subteam^ID>` while preserving `<https://…|label>` links and `<@u123>`
mentions) and `link_names: false` is sent explicitly; `blocks` is
caller-authored and is not rewritten. Both tasks gain an opt-in
`allow_mentions` port.

Also:
- WebhookNotifyTask no longer echoes a private destination's response body:
  reachability parity with FetchUrlTask is kept, but the `response` port
  was a working SSRF read primitive against e.g. 169.254.169.254.
- Response bodies stream with a 1MB ceiling (`SECURITY_LIMITS
  .webhookMaxResponseBodyBytes`) instead of buffering unbounded via
  `response.text()` — the failure path buffered unconditionally.
- A fetch rejection named `AbortError`/`TimeoutError` is classified as an
  abort (or a timeout) instead of falling through to a retryable
  `FETCH_NETWORK_ERROR`, so a cancelled workflow no longer looks transient.
- Slack/Discord gain `timeout` ports and all three default to 30s, so an
  endpoint that completes the handshake and never answers cannot hold a slot
  forever.
- A resolved webhook credential that is not an absolute http(s) URL fails
  with a configuration error naming the likely mistake, never echoing the
  value.
- A configured `url_credential_key` upgrades the `credential` entitlement
  from `optional: true` (which `evaluatePolicy` skips outright) to enforced.
- `success` output descriptions now say "Always true; a non-2xx response
  throws"; `url` descriptions note the value is stored in the graph JSON.
- README: 429/503 raise `RetryableJobError` but nothing retries them — these
  tasks run inline and task-graph has no retry consumer.

Co-authored-by: Claude <noreply@anthropic.com>
A notification POST carries the payload plus any caller headers, and for
Slack/Discord the URL itself is the credential. `safeFetch` was called with
no `redirect` option, so the default `"follow"` re-issued that exact request
at every `Location` — up to 20 hops, no same-origin check, no 303 method
downgrade, each hop re-classifying as PUBLIC. One `302` from a partner
endpoint hands the payload (and an `Authorization` header, if configured) to
another origin while the task reports `success: true`. Pass
`redirect: "error"` and fail closed: a 3xx now raises a permanent
`INVALID_URL` telling the operator to configure the final URL, with the
endpoint redacted to its origin as everywhere else. The `Location` value is
never read, so it cannot reach the message.

Slack and Discord set `includeBodyInError: true` unconditionally, splicing up
to 256 chars of the endpoint's reply into the thrown error — which
`WebhookNotifyTask` already suppresses for private destinations precisely so
the task cannot serve as an SSRF read primitive. Entitlement enforcement is
opt-in and `postWebhookJson` self-grants `allowPrivate` from the URL it uses,
so `slackNotify({url: "http://127.0.0.1:9200/_search", ...})` returned that
service's detailed error. Gate it at the choke point that already computes
`isPrivate` rather than in the two task files: `readSuccessBody` and
`includeBodyInError` are now a ceiling, forced to `false` for a private
destination. The status still reports; only the body is withheld.

Also:
- serialize the payload and build the header map before the request `try`. A
  circular or `BigInt` payload was reaching the catch, which labels anything
  unrecognized `NETWORK_ERROR` — retryable — so a permanent caller mistake
  was retried forever under a misleading message. It is now a permanent
  `CONFIGURATION` error.
- give the three `timeout` ports `minimum: 1`. `timeout: 0` armed no
  `AbortSignal.timeout` at all, letting a black-holed endpoint pin the task.
- flush the decoder on the capped body read, which dropped a trailing
  multi-byte character.

The README's "Direct task usage" snippets passed task INPUTS as the
constructor's first argument, which is the CONFIG — `additionalProperties:
false`, so every one of them threw `TaskConfigurationError` before any
request. Rewritten to the exported helpers, with one `defaults` example
showing the config-vs-input distinction and a test asserting each documented
form actually runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
…ening-p4d8qo

fix(tasks): refuse webhook redirects and withhold private failure bodies
…ge shape

PR #724 made `postWebhookJson` pass `redirect: "error"` so a secret-bearing
webhook POST is never re-sent to another origin. Detection of that refusal was
shape-based: `error.name === "TypeError" && /redirect/i.test(error.message)`,
because both transports threw a bare `TypeError` at the first 3xx.

That fails open. Reword the transport's message and the regex stops matching,
the refusal falls through to the generic catch, and it is relabelled
NETWORK_ERROR — a member of FETCH_URL_RETRYABLE_ERROR_CODES. The refused
redirect silently becomes a retried one, with no test failing.

Give the refusal a real discriminant instead. `FetchUrlErrorCode` is the
established error convention on this path — every other SafeFetch refusal
already throws through it — so add REDIRECT_NOT_FOLLOWED there rather than a
parallel error class, plus a single `createSafeFetchRedirectError` factory both
transports call and an exported `isSafeFetchRedirectError` guard consumers
match on. The code is non-retryable by construction, survives job-queue
persistence, and carries the requested URL and 3xx status; the `Location` is
still never read, so it cannot reach a message or a stack.

Behavior is unchanged: a refused webhook redirect stays a permanent
INVALID_URL-class error, endpoint reduced to its origin, Location absent.
Only the detection changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
…entinel-p4d8qo

fix(tasks): detect safeFetch redirect refusals by sentinel, not message shape
`redactWebhookUrlIn` only matched the literal full URL, but endpoints
routinely echo just a fragment of it: an Express 404 answers with the
path and no origin, and a validation error may quote the token alone.
Slack and Discord both set `includeBodyInError`, so such a body reached
the error message, the stack and the persisted diagnostics verbatim.

The path, the query and every individual path segment are now redacted
too, applied longest-first so a short segment cannot break a longer
candidate containing it. A candidate is admitted only when it clears
`SECURITY_LIMITS.webhookMinRedactableSegmentChars` and is not an
all-lowercase word — `services` and `webhooks` are real segments of the
Slack and Discord paths, and redacting them would corrupt ordinary prose.

Also adds `slackBlocksMaxDepth`, so `limits.ts` is touched once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
`text` was escaped but `blocks` was passed through verbatim, so a
`<!channel>` written into a section, a `fields[]` entry or an
`elements[]` entry still notified the whole workspace — and `blocks` is
as reachable from a pipe or a model as `text` is, which left the
neutering one field away from being bypassed.

`neutralizeSlackBroadcastsDeep` walks the structure and routes every
string leaf through the same escape as `text`, so there is one rule
rather than two. Escaping all leaves (not just rendered body text) is
side-effect-free — `<!` has no legitimate use in a `type`, `block_id`,
`action_id` or URL field — and covers block shapes without enumerating
them. Both ports are gated on `allow_mentions`. Input structures are
rebuilt, never mutated, and depth is capped by
`SECURITY_LIMITS.slackBlocksMaxDepth`, which terminates a cycle with the
same permanent configuration error serialization would raise anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
The `response.ok` branch truncated the body straight into the task
output port. Echo endpoints (webhook.site, RequestBin) reply 200 with
the request line, which carries the whole webhook URL — the credential —
into persisted, pipeable output.

The failure path's helper now runs on the success body too, and runs
BEFORE truncation: cutting first can slice a token in half so it no
longer matches, leaving a usable prefix behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
A `url_credential_key` the store could not answer fell through to the
`url` port and posted anyway, reporting success — so a locked store or a
mistyped key silently sent the notification to a different endpoint,
with nothing anywhere saying the configured credential was never used.

`resolveWebhookUrl` now takes whether the key was configured at all. The
resolver overwrites the port in place, so `execute` cannot see the raw
key; a store miss leaves the port present with value `undefined` while an
unconfigured port is absent, and `Object.hasOwn` is that discriminator.
The message names the port, never a key or a value, and is raised as a
permanent configuration error so the job layer does not retry it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
…g them

BREAKING CHANGE: posting to a loopback/RFC1918 destination now requires
the new `allow_private_destination` input on WebhookNotifyTask,
SlackNotifyTask and DiscordNotifyTask. Without it the post fails with
PRIVATE_DENIED before any request is made.

The destination is not knowable at entitlement-evaluation time:
`ITask.entitlements()` is synchronous and the enforcer checks it before
the runner resolves `format: "credential"` inputs. Deriving the
requirement from the URL therefore either failed open — grading a decoy
public `url` while the request went wherever the credential pointed — or
forced an unscoped `network:private` grant on every credential-using
instance, public destination or not. And `postWebhookJson` then
self-granted `allowPrivate` from whatever URL it had ended up with, so
an arbitrary credential value chose its own reachability.

The decision is now an explicit declared input, enforced at execute time
against the URL actually resolved. The entitlement is declared only when
the flag is set, scoped to the `url` port when that port is the
destination and unscoped (with a reason naming the credential store)
when it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
…9x9lby-notify-secrets

fix(tasks): close five secret-disclosure and gating holes in the notify tasks
… a private destination

`allow_private_destination` is an input, and a graph ROOT task's run-input is
applied by `TaskRunner.run()` — strictly after `TaskGraphRunner` has already
graded `task.entitlements()`. So a run-input carrying
`{ url: "http://169.254.169.254/...", allow_private_destination: true }` reached
`execute()` with a declaration the enforcer never saw, and the post went out
under a policy that denies `network:private`. The same window is what makes a
credential-resolved URL ungradeable at declaration time: the enforcer runs
before the credential resolver.

`postWebhookJson` now re-checks the `network:private` grant at execute time
against the URL actually resolved, via the registry the task is executing under
(`context.registry`). No enforcer registered means no policy to satisfy and the
post proceeds unchanged; a public destination is never checked.

Also tightens `webhookPrivateEntitlements` so only an explicit `false` opts out
of declaring `network:private`. The branch is unreachable while the input
schemas keep `default: false` (which is retained — every ordinary Slack/Discord
notification would otherwise demand the grant), but it means a later removal of
that default fails closed rather than open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
…anded to safeFetch

The transport enforces against these two arguments, so the outcome assertions
elsewhere only imply them. Pins that a public destination is fetched with
`allowPrivate: false` and no scopes, that a declared private one is scoped to
its own origin, and — the DNS-rebinding invariant — that
`allow_private_destination` never widens the transport for a public hostname.
All three notify tasks are covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
…gzrguj-notify-ssrf

fix(tasks): enforce the network:private grant at execute time for webhook notify tasks
Picks up the fix for the unhandled rejection raised when a webhook success
body is cancelled unread, which landed on main after this branch was cut.

Two adjacent-insertion conflicts, both resolved by keeping both sides:
  - packages/tasks/src/common.ts: DiscordNotifyTask and FetchUrlCredentials
    exports inserted at the same point in the alphabetised list.
  - packages/test/src/test/task/FetchUrlSsrf.test.ts: node: builtin imports
    and the getTestingLogger import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
Every case in NotifyTask.test.ts runs against a process-global safeFetch mock
installed in beforeAll, so none of them exercise the undici request, the
passthrough TransformStream, or the dispatcher lifecycle. That matters because
postWebhookJson cancels the success body unread for both Slack and Discord:
cancelling the returned readable errors the writable behind it, pipeTo rejects,
and an unhandled rejection terminates the process under Node's default
--unhandled-rejections=throw. A mocked Response has no pipe behind it.

Separate file rather than a block in NotifyTask.test.ts, so a real-transport
case cannot be poisoned by that file's global mock depending on describe order.
Carries the getSafeFetchImpl().name tripwire — without it the file is vacuous.

Covers both cancel sites: the success-body cancel, and readBodyText abandoning
an oversized failure body at webhookMaxResponseBodyBytes, which nothing else
exercises against the real transport. Plus dispatcher release on the cancel
path and the bodiless-204 branch that takes no TransformStream at all.

Verified against the pre-merge transport: the three cancel-path cases fail
(unhandled rejection observed), the tripwire and the 204 guard pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
The Block Kit escape pass rewrites every string leaf, on the reasoning that
`<!` is a broadcast sigil wherever Slack finds it. That is true of message
text and false of rich_text: an @channel ping there is an element SHAPE,
`{type: "broadcast", range: "channel"}`, with no `<!` anywhere in it. A group
ping is `{type: "usergroup", usergroup_id: "S…"}`. Both survived the escape
untouched, so caller-supplied or model-generated blocks could still notify a
whole workspace with `allow_mentions` unset.

Adds a structural pass ahead of the key-copy loop that rewrites such an element
to a plain text node. Rewrite rather than delete: dropping the element can leave
an `elements[]` empty, which Slack rejects — turning a security control into an
availability bug. `link_names: false` is already sent whenever mentions are
disallowed, so the literal `@channel` text cannot auto-link.

Matching on `type` alone rather than rich_text ancestry is deliberate: the only
false positive is a caller who wanted a live usergroup ping, which is exactly
what the control exists to stop.

The completeness claim was asserted in three places, all of which said the
lexical escape covered everything; each now states both halves and the residual
(a new structural element type Slack adds later is uncovered until listed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
The webhook URL is the credential, and redactWebhookUrlIn is what keeps it out
of a diagnostic that quotes an endpoint's reply. Two shapes escaped it.

Query values were never candidates. Only `pathname+search`, `search` and the
individual path segments were admitted, so a `?token=SUPERSECRET1` endpoint
that answers `bad token SUPERSECRET1` matched nothing — the whole `?token=…`
pair was a candidate but the echo quotes the value alone. `?token=` auth is an
ordinary deployment shape, not a corner case. Query values are now admitted in
both raw and decoded form; the raw query is split by hand rather than read from
searchParams, whose decoder turns `+` into a space and so would not match the
bytes an endpoint echoes.

All-lowercase candidates were exempted outright, on the theory that such a run
is a word rather than a token. A lowercase token is still a token. The exemption
existed to protect `services` and `webhooks`, the routing segments of the two
supported providers' paths, which are now named explicitly instead. Accepted
cost, stated in the JSDoc and pinned by a test: a generic webhook whose path
carries a long lowercase word has that word redacted from echoed diagnostics.

Also folds in `response.statusText`, which was interpolated into the message and
stored as `httpStatusText` with no redaction at all, and unlike the body was not
withheld for a private destination — leaving the SSRF read open through a
narrower channel, since a server can put anything in a reason phrase.

The length floor is unchanged and now applies to query values too, so a short
`?t=abc` still leaks. Same policy as segments, said out loud rather than
silently special-cased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
@sroussey sroussey linked an issue Aug 13, 2026 that may be closed by this pull request
@sroussey sroussey self-assigned this Aug 13, 2026
@sroussey
sroussey marked this pull request as draft August 13, 2026 03:47
@sroussey sroussey changed the title Merge main into the notify-tasks branch + real-transport notify tests Notify tasks: merge main, real-transport tests, Slack structural broadcasts, webhook redaction Aug 13, 2026
…d a job (#758)

A `Retry-After` header (or Discord's JSON `retry_after`) is remote-controlled
input, and both the webhook post path and FetchUrlTask turned it straight into
a Date with no ceiling. `Retry-After: 1e20` overflows the maximum representable
timestamp into an Invalid Date, and `Fri, 01 Jan 9999 00:00:00 GMT` parses
perfectly well into a date that parks the job for millennia.

The Invalid Date is the worse half: it passes `instanceof Date` in
JobQueueWorker.rescheduleJob, its NaN reaches `delaySeconds`, and
`new Date(NaN).toISOString()` throws a RangeError that rescheduleJob's own
catch swallows — no retry is scheduled, the claim is dropped, and the job is
stranded until lease expiry.

Adds SECURITY_LIMITS.httpRetryAfterMaxSeconds (24h) and a single
RetryAfter.ts funnel that every parse routes through, and makes the worker
reject an invalid date defensively rather than clamp it: the parse site owns
the policy, the worker only refuses the impossible.


Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

Co-authored-by: Claude <noreply@anthropic.com>
limiter,
});

const id = await storage.add({
…lack escape gap (#761)

Four independent fixes in the webhook notification path.

- The generic branch of toRedactedWebhookError built its detail from
  error.message alone, so every undici connection failure reported the same
  unactionable "fetch failed". One level of cause.message is now appended,
  redacted before truncation. The cause OBJECT is still never attached:
  formatErrorChainForDiagnostics persists every link's message and stack and
  would re-import the unredacted URL.
- leaksUrl gated the redaction rewrite on message and url only, so a typed
  error whose stack embeds the webhook URL was returned untouched. Stacks are
  persisted, so this leaked the token.
- readBodyText returned a mid-stream failure exactly as a complete read, and
  the fragment was surfaced as the task's response under success: true. It now
  reports completeness and the caller marks a short read. Not a throw: the POST
  already returned 2xx, so throwing would repost the message on retry.
- The structural broadcast rewrite built its replacement text from the
  caller-controlled range and was the one string leaf in the traversal that
  skipped the lexical escape.


Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.99% 37626 / 62718
🔵 Statements 59.5% 39471 / 66334
🔵 Functions 60.87% 7283 / 11964
🔵 Branches 48.3% 19209 / 39769
File CoverageNo changed files found.
Generated in workflow #3087 for commit da53436 by the Vitest Coverage Report Action

claude and others added 8 commits August 13, 2026 17:39
…Error or fire after 1 ms

`AbortSignal.timeout` validates its delay as a uint32 integer, but the
`timeout` port was declared `type: "number", minimum: 1` with no maximum in
all three notify tasks. A fractional value therefore passed schema validation
and reached the timer, which threw a bare `RangeError` — not a
`FetchUrlJobError` — so a queued consumer could not classify it and would
retry a permanent configuration mistake forever. A value above the signed
32-bit range also passed validation and was silently clamped to 1 ms with a
`TimeoutOverflowWarning`, so "effectively never time out" aborted instantly
and the failure was reported against the endpoint.

- declare the port `type: "integer"` with `maximum: 2147483647` in all three
  tasks, sharing one exported literal with the runtime guard so the two
  cannot drift
- validate in `postWebhookJson` before the signal is armed, raising a
  classifiable `FetchUrlErrorCode.CONFIGURATION` error naming the bound

The old `> 0` test silently meant "wait forever", contradicting the port's
own documented behavior; `undefined` still means no timer and is only
reachable from a direct `postWebhookJson` caller.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKvKnyVeQQQCm6FhtaLyMa
…clares

BREAKING CHANGE: `allow_private_destination` now widens the transport for any
destination it is set on, and any such destination's reply body and reason
phrase are withheld.

`allowPrivate` was derived from `classifyUrl`, which is string-only by
construction and never resolves DNS. A hostname that is no literal IP and
matches no reserved suffix classifies public, so the flag was inert for
exactly the case it names: under split-horizon DNS a `hooks.mycorp.com`
resolving to 10.1.2.3 was refused at connect time with a message asking for
the `network:private` grant the operator already held, and no configuration
short of hard-coding the address made the post work.

The declaration now governs the transport and the GRANT still authorizes it:
`assertPrivateDestinationGranted` runs for every declared private
destination rather than only for a URL that reads private, so the set of
requests receiving the widened transport is a superset of the set that is
entitlement-checked — where previously the public-looking case was checked at
all. The resource pattern is computed once and used both for the grant check
and for `privateResourceScopes`, so the pattern graded and the scope enforced
cannot diverge. Redirects stay refused, so a `Location` cannot pivot off the
granted origin.

Body, failure-body and reason-phrase suppression move to the declaration for
the same reason: the URL alone cannot say whether the host was internal, so a
caller who declared it may be private never gets its reply back.

`WebhookNotifyTask` no longer classifies the URL a second time — that
duplicate was the drift — and passes `readSuccessBody: true` as the ceiling it
is, leaving `postWebhookJson` the single decider.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKvKnyVeQQQCm6FhtaLyMa
…hxoj5s-notify-timeout

fix(tasks): bound the webhook timeout port so it cannot throw a RangeError or fire after 1 ms
…hxoj5s-notify-private-destination

fix(tasks)!: let allow_private_destination govern the transport it declares
`neutralizeSlackBroadcasts` escaped every `<!` occurrence, but `<!` is
Slack's control-sequence sigil rather than a broadcast sigil: the documented
date-formatting token has the same shape
(`<!date^1700000000^{date_short}|Nov 14>`) and can notify nobody.

Escaping it is pure collateral damage. Slack un-escapes the entity for
display, so a message reading "Deploy at <!date^…>" showed the raw token
instead of a localized date, and the only opt-out — `allow_mentions: true` —
simultaneously disables the structural broadcast/usergroup rewrite and drops
`link_names: false`, so recovering a date meant accepting live channel-wide
pings in the same field.

The escape now skips a `<!` followed by `date^`. The exemption is an exact
lowercase prefix matched at the `<!` itself, so a `<!channel>` inside a date
token's fallback text is still escaped and a case variant (`<!DATE^`) is
escaped too — whether Slack accepts one is unverified, so it fails closed.
`neutralizeSlackBroadcastsDeep` calls this function, so the exemption reaches
every string leaf of `blocks` without a second copy of the rule.

No new port, and `allow_mentions` keeps its default of `false`: with the date
token exempt, everything the escape still removes is a mention, so the one
control governs one concern and splitting it would add a port with no
behavior behind it. That rationale is recorded in the JSDoc.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKvKnyVeQQQCm6FhtaLyMa
…hxoj5s-slack-date-token

fix(tasks): exempt Slack's date token from the broadcast escape
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Webhook Notification Tasks

2 participants