What
packages/tasks/src/task/FileLoaderTask.server.ts resolves a local path the same way FileGrepTask.server did before #821's follow-up hardening:
if (url.startsWith("http://") || url.startsWith("https://")) { // :30 — case-SENSITIVE
return super.execute(input, context);
}
if (url.startsWith("file://")) {
url = url.slice(7); // :38-40 — no percent-decode, no host check
}
...
const fileContent = await readFile(url, { encoding: "utf-8" }); // :51 — unsandboxed
So:
- no containment — no root, no allowlist, no
path.resolve + prefix check, no realpath, and a relative path resolves against process.cwd();
- no entitlement — the class declares no
entitlements(), so Task.entitlements() returns EMPTY_ENTITLEMENTS even though Entitlements.FILESYSTEM_READ exists (TaskEntitlements.ts:77). A graph containing it passes an enforcer that grants no filesystem access;
file:// is sliced, not parsed — a percent-encoded name (a%20b.txt) addresses the wrong path, and file://evil.example/etc/passwd is silently read as evil.example/etc/passwd;
- the scheme test is case-sensitive —
HTTP://x falls through to the filesystem branch.
The task is registered globally by node.ts and electron.ts, so any workflow that can set a task input can read any file the process can.
Why it was not fixed in the FileGrep PR
It owns its own format detection / image / pdf surface and its own test file, and widening that diff hurts review. The helper the grep fix introduced — resolveLocalFilePath(url, { roots }) and isHttpUrl(url) in packages/tasks/src/util/LocalFilePath.server.ts — is deliberately task-agnostic, so the fix here is roughly:
if (isHttpUrl(url)) return super.execute(input, context);
const file = resolveLocalFilePath(url, { roots: this.config.roots });
plus a roots config entry, a configSchema() declaring it, and a filesystem:read entitlements() declaration scoped to the resolved path (mirroring FileGrepTask.server).
Acceptance
file:// URLs parsed via fileURLToPath, remote hosts rejected
- path realpath'd, then checked against opt-in
roots (check AFTER realpath, so a symlink cannot escape)
- non-regular files refused
filesystem:read declared, scoped to the resolved path
isHttpUrl used for the scheme test
- tests mirroring
FileGrepTask.server.test.ts
What
packages/tasks/src/task/FileLoaderTask.server.tsresolves a local path the same wayFileGrepTask.serverdid before #821's follow-up hardening:So:
path.resolve+ prefix check, norealpath, and a relative path resolves againstprocess.cwd();entitlements(), soTask.entitlements()returnsEMPTY_ENTITLEMENTSeven thoughEntitlements.FILESYSTEM_READexists (TaskEntitlements.ts:77). A graph containing it passes an enforcer that grants no filesystem access;file://is sliced, not parsed — a percent-encoded name (a%20b.txt) addresses the wrong path, andfile://evil.example/etc/passwdis silently read asevil.example/etc/passwd;HTTP://xfalls through to the filesystem branch.The task is registered globally by
node.tsandelectron.ts, so any workflow that can set a task input can read any file the process can.Why it was not fixed in the FileGrep PR
It owns its own format detection / image / pdf surface and its own test file, and widening that diff hurts review. The helper the grep fix introduced —
resolveLocalFilePath(url, { roots })andisHttpUrl(url)inpackages/tasks/src/util/LocalFilePath.server.ts— is deliberately task-agnostic, so the fix here is roughly:plus a
rootsconfig entry, aconfigSchema()declaring it, and afilesystem:readentitlements()declaration scoped to the resolved path (mirroringFileGrepTask.server).Acceptance
file://URLs parsed viafileURLToPath, remote hosts rejectedroots(check AFTER realpath, so a symlink cannot escape)filesystem:readdeclared, scoped to the resolved pathisHttpUrlused for the scheme testFileGrepTask.server.test.ts