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:
7
.changeset/fn-7619-attach-boundary.md
Normal file
7
.changeset/fn-7619-attach-boundary.md
Normal 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).
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user