fix(tasks): enforce the network:private grant at execute time for webhook notify tasks - #736
Merged
sroussey merged 2 commits intoAug 10, 2026
Conversation
… 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
Coverage Report
File CoverageNo changed files found. |
sroussey
merged commit Aug 10, 2026
46309a2
into
claude/libs-issues-triage-prs-mh6x2o-241
10 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks onto #678 — base is
claude/libs-issues-triage-prs-mh6x2o-241, notmain. Review after that PR.The SSRF
allow_private_destinationis an input, and a graph root task's run-input is applied byTaskRunner.run()— strictly afterTaskGraphRunnerhas already calledactiveEnforcer.checkTask(task)ontask.entitlements(). The declaration the enforcer graded is therefore not necessarily the oneexecute()receives.Concretely, under an enforcer that denies
network:private(thebrowserprofile grantsnetwork:http,credentialand friends but deliberately notnetwork:private):...resolved successfully and posted to the cloud metadata endpoint. The task instance's
allow_private_destinationwas still the schema defaultfalsewhen the enforcer graded it, so nonetwork:privaterequirement was ever declared, and by the timeexecute()ran the flag wastrueandpostWebhookJsonhad nothing left to consult. The same window is what makes a credential-resolved URL ungradeable at declaration time — the enforcer runs before the credential resolver, so the URL does not exist yet.Reproduced first as a failing test: before the source change the metadata endpoint was posted to (
mockFetchcalled once) and the run resolved.The fix
postWebhookJsonnow re-checks thenetwork:privategrant at execute time, against the URL actually resolved, using the registry the task is executing under (context.registry, threaded through by all three notify tasks). New exported helperassertPrivateDestinationGrantedinpackages/tasks/src/util/WebhookPost.ts; it throwsPRIVATE_DENIEDwith the origin only (never the path — the webhook URL is the credential).grantCoversResources).Secondary, defence in depth:
webhookPrivateEntitlementsnow opts out of declaringnetwork:privateonly on an explicitfalse(was: only declared on an explicittrue). The branch is unreachable while the three input schemas keepdefault: false— which is retained deliberately, since removing it would make every ordinary Slack/Discord/webhook post demandnetwork:private— but it means a later removal of that default fails closed rather than open. The four existingprivate-destination entitlementstests that assignrunInputDatawholesale now passallow_private_destination: falseexplicitly; their assertions are unchanged, and a new test pins that an ordinary instance carrying the schema default still needs no grant.Also, the MEDIUM follow-up (test-only): the
allowPrivate/privateResourceScopesarguments handed tosafeFetchare now pinned directly, including the DNS-rebinding invariant thatallow_private_destinationnever widens the transport for a public hostname.Behavioural caveats worth flagging
ENTITLEMENT_ENFORCERbut runs withenforceEntitlements: falsewill see its private webhook posts checked where previously nothing was checked.IExecuteContextexposes no "enforcement is active this run" signal, so registry presence is the only available seam; adding such a field is deliberately out of scope here.askpolicy may be consulted twice. The declaration-time check and this execute-time check are twocheckAll/checkTaskcalls.createPolicyEnforcerconsultsresolver.lookupbefore prompting and saves the answer, so the second one is answered from cache rather than re-prompting — but implementations ofIEntitlementResolverthat do not cache would prompt again.Files
packages/tasks/src/util/WebhookPost.ts— newassertPrivateDestinationGranted,WebhookPostRequest.registry,!== falsetightening, doc updatespackages/tasks/src/task/{Webhook,Slack,Discord}NotifyTask.ts— one line each, passingcontext.registrypackages/tasks/README.md— three private-destination bullets updatedpackages/test/src/test/task/NotifyTask.test.ts— regression + companion testsTests
bun scripts/test.ts task vitest— 73 files, 1235 passed, 24 skippedbun scripts/test.ts graph vitest— 126 files, 1088 passedbun run format,bun run build:typescleanNotifyTask.test.tsgoes from 79 to 89 tests. New coverage: run-input smuggling is refused with zero fetches; a granted scope permits the identical run (a gate, not a blanket refusal); a grant scoped elsewhere does not cover the destination; a credential-resolved private URL is checked (the case declaration-time enforcement can never reach); and no registered enforcer leaves a declared private post working.🤖 Generated with Claude Code
https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
Generated by Claude Code