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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
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 { tmpdir } from "node:os";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { setTimeout as delay } from "node:timers/promises";
|
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");
|
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 () => {
|
it("rejects unsupported file types", async () => {
|
||||||
const createTool = api.tools.get("fn_task_create")!;
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
await createTool.execute(
|
await createTool.execute(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ import {
|
|||||||
traitListParams,
|
traitListParams,
|
||||||
} from "@fusion/engine";
|
} from "@fusion/engine";
|
||||||
import * as dashboard from "@fusion/dashboard";
|
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 { readFile } from "node:fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
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;
|
let content: Buffer;
|
||||||
try {
|
try {
|
||||||
content = await readFile(filePath);
|
content = await readFile(filePath);
|
||||||
|
|||||||
Reference in New Issue
Block a user