FN-7619: guard fn_task_attach against worktree boundary bypass

Add a path-containment check to fn_task_attach so it can no longer read files outside the task's worktree via traversal or absolute paths.

- Resolve the requested path and confine it to ctx.cwd (the task worktree) before any readFile call, rejecting "../" traversal, absolute paths, and other boundary-escaping inputs
- Add regression tests in extension.test.ts covering traversal/absolute-path attack vectors
- Add changeset (patch, category: security) documenting the fix

Files changed:
 .changeset/fn-7619-attach-boundary.md        |   7 ++
 packages/cli/src/__tests__/extension.test.ts | 133 ++++++++++++++++++++++++++-
 packages/cli/src/extension.ts                |  23 ++++-
 3 files changed, 161 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7619
Fusion-Task-Lineage: d35d9218-d678-4989-945d-6c1e1a322c5c
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 21:51:58 -07:00
parent 9e5c025113
commit ca8447304f
3 changed files with 161 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: fn_task_attach now refuses to read files outside the task worktree boundary.
category: security
dev: Adds a path-containment guard (confine to ctx.cwd) before readFile in the fn_task_attach tool; rejects traversal/absolute/@-prefixed escaping paths. Regression tests in packages/cli/src/__tests__/extension.test.ts. Fixes FN-7619 (flagged out-of-scope during FN-7608).

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { dirname, join, relative } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
@@ -1183,6 +1183,137 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(result.details.attachment.originalName).toBe("test.txt");
});
it("attaches a file from an in-boundary nested subdirectory", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
await mkdir(join(tmpDir, "nested", "dir"), { recursive: true });
const testFile = join(tmpDir, "nested", "dir", "inner.txt");
await writeFile(testFile, "inner content");
const attachTool = api.tools.get("fn_task_attach")!;
const result = await attachTool.execute(
"call-1",
{ id: "FN-001", path: "nested/dir/inner.txt" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Attached to FN-001");
expect(result.details.attachment.originalName).toBe("inner.txt");
});
/*
* FNXC:CliTaskAttach 2026-07-05-00:00:
* Regression coverage for FN-7619 — fn_task_attach must reject any path
* (relative traversal, absolute, or @-prefixed traversal) that resolves
* outside the task worktree boundary (ctx.cwd), and must never create an
* attachment when it does.
*/
describe("worktree boundary guard (FN-7619)", () => {
let outsideDir: string;
let outsideFile: string;
beforeEach(async () => {
outsideDir = await mkdtemp(join(tmpdir(), "kb-ext-test-outside-"));
outsideFile = join(outsideDir, "secret.txt");
await writeFile(outsideFile, "top secret contents");
});
afterEach(async () => {
await rm(outsideDir, { recursive: true, force: true });
});
it("rejects a relative traversal path escaping the worktree", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const relPath = join(relative(tmpDir, outsideDir), "secret.txt");
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: relPath },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow(/boundary|outside/i);
const store = new TaskStore(tmpDir);
const task = await store.getTask("FN-001");
expect(task?.attachments ?? []).toHaveLength(0);
});
it("rejects an absolute path outside the worktree", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: outsideFile },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow(/boundary|outside/i);
const store = new TaskStore(tmpDir);
const task = await store.getTask("FN-001");
expect(task?.attachments ?? []).toHaveLength(0);
});
it("rejects an @-prefixed traversal path escaping the worktree", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const relPath = join(relative(tmpDir, outsideDir), "secret.txt");
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: `@${relPath}` },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow(/boundary|outside/i);
const store = new TaskStore(tmpDir);
const task = await store.getTask("FN-001");
expect(task?.attachments ?? []).toHaveLength(0);
});
});
it("rejects unsupported file types", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(

View File

@@ -66,7 +66,7 @@ import {
traitListParams,
} from "@fusion/engine";
import * as dashboard from "@fusion/dashboard";
import { resolve, basename, extname, join } from "node:path";
import { resolve, relative, isAbsolute, sep, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process";
@@ -1226,6 +1226,27 @@ export default function kbExtension(pi: ExtensionAPI) {
);
}
/*
* FNXC:CliTaskAttach 2026-07-05-00:00:
* fn_task_attach must confine reads to the task worktree boundary (ctx.cwd) to
* prevent a path-traversal / absolute-path read-boundary bypass — an agent could
* previously pass "../../../etc/hosts" or an absolute path (with an allowed
* extension) and exfiltrate arbitrary files into a task's attachments. The guard
* must run BEFORE readFile so an out-of-boundary path is never opened, even to
* fail. The boundary is intentionally ctx.cwd (the worktree) — not a broader
* project root — to avoid re-exposing sibling worktrees. (FN-7619, flagged
* out-of-scope during FN-7608.)
*/
const boundaryRoot = resolve(ctx.cwd);
const rel = relative(boundaryRoot, filePath);
const escapesBoundary =
rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
if (escapesBoundary) {
throw new Error(
`Refusing to attach file outside the task worktree boundary: ${params.path}`,
);
}
let content: Buffer;
try {
content = await readFile(filePath);