Skip to content

fix(tasks)!: contain the server filesystem tasks by default - #843

Merged
sroussey merged 1 commit into
mainfrom
claude/branch-security-review-xs0tph-libs-fs-roots
Aug 20, 2026
Merged

fix(tasks)!: contain the server filesystem tasks by default#843
sroussey merged 1 commit into
mainfrom
claude/branch-security-review-xs0tph-libs-fs-roots

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

⚠️ Breaking change, and the migration

Two published behaviours in @workglow/tasks change. Both are the fix.

1. registerCommonTasks() no longer registers the filesystem tasks. In the node and electron builds it used to add FileGrepTask, FileLoaderTask and FileSedTask to TaskRegistry. It no longer does.

import { registerCommonTasks, registerFileSystemTasks } from "@workglow/tasks";

registerCommonTasks();
// Only where every graph that can reach the registry is trusted:
registerFileSystemTasks();

The classes are exported exactly as before — constructing one directly is unchanged. Only ambient registry availability became opt-in.

2. A local path must now resolve inside config.roots, which defaults to [process.cwd()]. It used to default to no containment at all. An absolute path outside the working directory now needs explicit roots, or the new explicit opt-out:

new FileGrepTask({ roots: ["/srv/data"], defaults: { url, pattern } });
new FileGrepTask({ allowAnyRoot: true, defaults: { url, pattern } }); // read anything

allowAnyRoot must be the literal true; omitting roots never implies it.

In-repo callers of registerCommonTasks() (8 sites across examples and tests) use none of the file tasks, so nothing in this repo needed the new call.


Why

F1 — the server file tasks defaulted to unrestricted filesystem read

resolveLocalFilePath skipped containment whenever roots was undefined, on the stated grounds that "the enforced control is the filesystem:read entitlement". It is not. TaskGraphRunner consults an enforcer only under enforceEntitlements, and ENTITLEMENT_ENFORCER has no default factory — so setting the flag without registering one throws. Neither is a default, which leaves exactly two states: off, and explicitly configured. In the off state an embedder that called registerCommonTasks() had a task in the ambient registry that read any path the process could open.

assertResolvedPathDeclared did not narrow this. It recomputes the path from the same input through the same resolver — a declare-then-swap guard, not containment — so with no roots both sides agree and every path passes.

FileLoaderTask.server was strictly worse, and is why the fix cannot stop at roots: it declared no entitlement at all, honoured no roots, and reached the filesystem via url.slice(7), which neither percent-decodes nor rejects a file:// host. Fixing grep and sed while leaving it registered would have been theatre. It now runs the same resolver and carries the same configSchema() plus static/instance entitlements() pair its siblings do. metadata.url is still the caller's path, so the output shape is unchanged.

The registration split is the half that survives untrusted input. A serialized node is built as {...item.config, id, defaults}, so a graph naming FileGrepTask supplies its own config and can state roots: ["/"] itself — no default this package picks constrains it. The only control left against hostile graph JSON is the type not resolving at all. The cwd default is the defence for the other case: a trusting embedder that authored the graph itself.

F7 — root resolution was order-dependent

realpathSync(root) ran inside the some() predicate, and some short-circuits. Reproduced: ["good", "missing"] returned true without ever resolving the broken root, while ["missing", "good"] threw — same config, same input, opposite outcome by array order. Every root is now resolved before any containment verdict is taken, so a misconfigured root fails always rather than sometimes. That is both the deterministic direction and the safer one.

The two findings share LocalFilePath.server.ts, and F7 is meaningless until F1 makes roots load-bearing, so they ship together.


Tests

Every new test was verified to fail before the source fix and pass after (source changes stashed, tests kept):

test pre-fix failure
grep / sed / loader: refuses a path outside the cwd when no roots are configured promise resolved "{ text: 'classified', …}" — it read the file
grep: an unresolvable root is rejected regardless of its position promise resolved for the [good, missing] ordering
grep: allowAnyRoot restores the unrestricted read Additional property allowAnyRoot is not allowed
RegisterFileSystemTasks.test.ts (all 4 registry assertions) registerFileSystemTasks did not exist; the three types were in the registry after registerCommonTasks()
loader: containment / symlink-escape / scoped-declaration suite Additional property roots is not allowed — the task supported no roots at all

New packages/test/src/test/task/RegisterFileSystemTasks.test.ts pins both directions of the split and that the classes stay constructible without registering.

~35 existing server tests that constructed these tasks with no roots now state roots: [testDir]. That churn is the point — each one now says which directory it means to read. Two exceptions state their intent differently: the /dev/zero non-regular-file tests use roots: ["/dev"] so the device is inside the root and still refused, and the entitlement tests' /etc/passwd denial uses roots: ["/etc"] so the enforcer is what refuses it rather than containment — otherwise the policy under test would never be consulted.

Test output (after the fix)

 ✓ packages/test/src/test/task/FileGrepTask.server.test.ts
 ✓ packages/test/src/test/task/FileSedTask.server.test.ts
 ✓ packages/test/src/test/task/FileLoaderTask.server.test.ts
 ✓ packages/test/src/test/task/FileGrepEntitlements.test.ts
 ✓ packages/test/src/test/task/FileSedEntitlements.test.ts
 ✓ packages/test/src/test/task/RegisterFileSystemTasks.test.ts

 Test Files  6 passed (6)
      Tests  97 passed (97)

Whole packages/test/src/test/task/ directory: 1216 passed, 24 skipped, 1 failed. The one failure is FetchTask.test.ts > an HTTP error cancels the response body instead of abandoning it, which fails identically on main with these changes stashed — it is a separate, unreleased finding scoped to a follow-up PR.

Also clean: turbo run build-types --filter=@workglow/tasks (5 packages), eslint and prettier on every changed file, and tsgo --noEmit over the six changed test files.


Also updated

  • packages/tasks/README.md gains a Filesystem Tasks (server builds) section naming registerFileSystemTasks as the migration and documenting the root policy.
  • The node.ts / electron.ts docblocks explain why registration — not a config default — is the boundary against untrusted graph JSON.
  • The roots docblock in LocalFilePathOptions and both task config interfaces: the old "undefined means unrestricted — the enforced control is the filesystem:read entitlement" sentence was itself the defect and is replaced with the real rule plus a note that the entitlement path is off unless enforceEntitlements is set with a registered enforcer.

electron.ts carries the identical server registration and is split the same way; browser.ts is untouched, since the cross-platform classes reach http(s) through FetchUrlTask and touch no filesystem.


Generated by Claude Code

BREAKING CHANGE: `registerCommonTasks()` in the node and electron builds no
longer registers `FileGrepTask`, `FileLoaderTask` or `FileSedTask`. Call the
new `registerFileSystemTasks()` to restore them. Separately, a local path now
has to resolve inside `config.roots`, which defaults to `[process.cwd()]`
rather than to "anywhere"; pass explicit `roots`, or `allowAnyRoot: true`, to
read outside the working directory. The classes are exported unchanged — only
ambient registry availability and the containment default moved.

Two states existed and neither contained anything. `resolveLocalFilePath`
skipped containment whenever `roots` was `undefined`, on the stated grounds
that "the enforced control is the `filesystem:read` entitlement". It is not:
`TaskGraphRunner` consults an enforcer only under `enforceEntitlements`, and
`ENTITLEMENT_ENFORCER` has no default factory, so the flag without a
registered enforcer throws. Neither is a default. An embedder that called
`registerCommonTasks()` and ran a graph therefore had a task in the ambient
registry that read any path the process could open, and
`assertResolvedPathDeclared` did not narrow it — it recomputes the path from
the same input through the same resolver, so with no roots both sides agree
and every path passes.

`FileLoaderTask.server` was worse than the other two and is why the fix cannot
stop at `roots`: it declared no entitlement at all, honoured no roots, and
reached the filesystem through `url.slice(7)`, which neither percent-decodes
nor rejects a `file://` host. It now runs the same resolver and carries the
same `configSchema()` / static + instance `entitlements()` pair its siblings
do, with `metadata.url` still the caller's path so the output shape is
unchanged.

The registration split is the half that survives untrusted input. A serialized
node is built as `{...item.config, id, defaults}`, so a graph that names
`FileGrepTask` supplies its own config and can state `roots: ["/"]` itself —
no default this package picks constrains it. The only control left is the type
not resolving. The cwd default is the defence for the other case, a trusting
embedder that authored the graph itself.

Root resolution is also order-independent now. `realpathSync(root)` ran inside
the `some()` predicate, which short-circuits: `[good, missing]` returned true
without ever resolving the broken root while `[missing, good]` threw, on
identical input. Every root is resolved before any containment verdict, so a
misconfigured one fails always rather than sometimes — the deterministic
direction and the safer one.

The ~35 existing server tests that constructed these tasks with no `roots`
now state `roots: [testDir]`. That churn is the point: each one says which
directory it means to read.
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 58.64% 33237 / 56674
🔵 Statements 58.33% 34754 / 59581
🔵 Functions 59.85% 6443 / 10764
🔵 Branches 47.09% 16808 / 35689
File CoverageNo changed files found.
Generated in workflow #3237 for commit 2cebe68 by the Vitest Coverage Report Action

@sroussey
sroussey merged commit 5898a7e into main Aug 20, 2026
15 checks passed
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.

2 participants