What
Entitlements are computed once, at graph start, over graph.getTasks():
TaskGraphRunner.ts:895-909 → computeGraphEntitlements → GraphEntitlementUtils.ts:59 iterates graph.getTasks() and unions each task's entitlements().
A task created inside execute() via context.own(...) is not in that snapshot — it does not exist yet when the snapshot is taken. And the runtime re-check at TaskGraphRunner.ts:547-558 fires only in runTask, i.e. for graph-scheduled tasks; TaskRunner.ts:1073-1077 states in-source that owned children run via child.run(), which never reaches the enforcer.
So a task that declares nothing can own a child that declares — and performs — anything.
Worked example
FileGrepTask.execute() owns a FetchUrlTask and hands it the caller's URL. Pre-hardening the grep task declared no entitlements at all, so:
- the graph snapshot saw an empty entitlement set and the enforcer passed;
- the owned
FetchUrlTask then classified the URL itself (FetchUrlTask.ts:526-534) and set allowPrivate: true because classifyUrl(input.url).kind === "private";
assertResolvedDestinationDeclared passed, because the child's own runInputData.url is the private URL — the check compares the resolved destination against the child's declaration, and the child declared it.
Net effect: a browser-profile graph reached http://169.254.169.254/latest/meta-data/iam/security-credentials/.
The grep task now declares its owned fetch's entitlements itself (fetchUrlEntitlementsFor + hasDynamicEntitlements), which closes that instance. The framework hole is general: any task can do the same by owning a child, and nothing makes the declaration obligation visible.
Proposed directions
Either:
- Refuse an
own() whose child declares beyond the parent — compute the child's entitlements at own() time and throw if they are not covered by the parent's declaration. Fail-fast, no enforcer needed, but requires the child's URL/input to be known at own() time (often it is not).
- Route owned children through
activeEnforcer.checkTask — give context.own() access to the run context's enforcer and check the child before it runs. Catches the late-bound case, at the cost of a check on a hot path.
(1) is cheaper and catches the static case; (2) is the one that actually contains a child whose input is derived inside execute(). They compose.
References
packages/task-graph/src/task-graph/TaskGraphRunner.ts:547-558, :895-909
packages/task-graph/src/task-graph/GraphEntitlementUtils.ts:59
packages/task-graph/src/task/TaskRunner.ts:1073-1077
packages/tasks/src/task/FetchUrlTask.ts:526-534
Found while hardening FileGrepTask (PR #821 follow-up).
What
Entitlements are computed once, at graph start, over
graph.getTasks():TaskGraphRunner.ts:895-909→computeGraphEntitlements→GraphEntitlementUtils.ts:59iteratesgraph.getTasks()and unions each task'sentitlements().A task created inside
execute()viacontext.own(...)is not in that snapshot — it does not exist yet when the snapshot is taken. And the runtime re-check atTaskGraphRunner.ts:547-558fires only inrunTask, i.e. for graph-scheduled tasks;TaskRunner.ts:1073-1077states in-source that owned children run viachild.run(), which never reaches the enforcer.So a task that declares nothing can own a child that declares — and performs — anything.
Worked example
FileGrepTask.execute()owns aFetchUrlTaskand hands it the caller's URL. Pre-hardening the grep task declared no entitlements at all, so:FetchUrlTaskthen classified the URL itself (FetchUrlTask.ts:526-534) and setallowPrivate: truebecauseclassifyUrl(input.url).kind === "private";assertResolvedDestinationDeclaredpassed, because the child's ownrunInputData.urlis the private URL — the check compares the resolved destination against the child's declaration, and the child declared it.Net effect: a browser-profile graph reached
http://169.254.169.254/latest/meta-data/iam/security-credentials/.The grep task now declares its owned fetch's entitlements itself (
fetchUrlEntitlementsFor+hasDynamicEntitlements), which closes that instance. The framework hole is general: any task can do the same by owning a child, and nothing makes the declaration obligation visible.Proposed directions
Either:
own()whose child declares beyond the parent — compute the child's entitlements atown()time and throw if they are not covered by the parent's declaration. Fail-fast, no enforcer needed, but requires the child's URL/input to be known atown()time (often it is not).activeEnforcer.checkTask— givecontext.own()access to the run context's enforcer and check the child before it runs. Catches the late-bound case, at the cost of a check on a hot path.(1) is cheaper and catches the static case; (2) is the one that actually contains a child whose input is derived inside
execute(). They compose.References
packages/task-graph/src/task-graph/TaskGraphRunner.ts:547-558,:895-909packages/task-graph/src/task-graph/GraphEntitlementUtils.ts:59packages/task-graph/src/task/TaskRunner.ts:1073-1077packages/tasks/src/task/FetchUrlTask.ts:526-534Found while hardening
FileGrepTask(PR #821 follow-up).