diff --git a/.changeset/fn-7619-attach-boundary.md b/.changeset/fn-7619-attach-boundary.md new file mode 100644 index 0000000000..de28e3d6a7 --- /dev/null +++ b/.changeset/fn-7619-attach-boundary.md @@ -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). diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index d10b7fce03..0daaca28f9 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -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( diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 11d0ad7c6e..85b4d33ce8 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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);