diff --git a/.changeset/fn-7264-absolute-file-browser-paths.md b/.changeset/fn-7264-absolute-file-browser-paths.md
new file mode 100644
index 0000000000..216d34b1e4
--- /dev/null
+++ b/.changeset/fn-7264-absolute-file-browser-paths.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Add a project setting for absolute workspace file-browser paths.
+category: feature
+dev: Adds allowAbsoluteFileBrowserPaths for workspace file-browser routes while keeping the default confined.
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index b6fa7748df..9fc007ad54 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -351,6 +351,8 @@ show a short redirect stub linking to the workflow editor (for one release).
Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`.
+Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteFileBrowserPaths` is intentionally absent from global settings so one project's local-admin browsing policy cannot silently widen another project's workspace boundary.
+
> **Moved keys retained for reference.** Some rows below — the step-execution,
> review/approval, and per-phase model-lane keys listed under
> [Where did my setting go?](#where-did-my-setting-go) — are no longer project
@@ -386,6 +388,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. |
| `ignoreHiddenOverlapPaths` | `boolean` | `true` | Exclude hidden dot paths from overlap serialization by default. A hidden path is any normalized project-relative path with a segment beginning with `.`, such as `.fusion/tasks/FN-1/PROMPT.md`, `.changeset/fix.md`, `.github/workflows/ci.yml`, `.env`, or `packages/.cache/out.js`. Set to `false` to restore legacy strict counting of dot paths. Explicit `overlapIgnorePaths` entries still apply in addition to this default filter, and still apply when hidden-path filtering is disabled. |
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
+| `allowAbsoluteFileBrowserPaths` | `boolean` | `false` | Project-scoped Settings → General toggle for the workspace file browser. When enabled, slash-prefixed paths such as `/tmp` can be listed/read/written/downloaded through workspace file-browser routes while keeping existing file-size, binary, type, null-byte, traversal, and permission checks. Windows drive-letter paths remain blocked, and task-local file routes, memory APIs, worktree-copy validation, plugin bundle paths, and other validators are unchanged. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every specified task to todo without manual approval; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. |
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts
index 9386d6ae0b..9faee3587a 100644
--- a/packages/core/src/__tests__/settings-parity.test.ts
+++ b/packages/core/src/__tests__/settings-parity.test.ts
@@ -98,6 +98,14 @@ describe("settings key parity", () => {
expect(PROJECT_SETTINGS_KEYS).toContain("ignoreHiddenOverlapPaths");
});
+ it("defaults absolute file-browser paths off and keeps it project-scoped", () => {
+ expect(DEFAULT_PROJECT_SETTINGS.allowAbsoluteFileBrowserPaths).toBe(false);
+ expect(isProjectSettingsKey("allowAbsoluteFileBrowserPaths")).toBe(true);
+ expect(isGlobalSettingsKey("allowAbsoluteFileBrowserPaths")).toBe(false);
+ expect(PROJECT_SETTINGS_KEYS).toContain("allowAbsoluteFileBrowserPaths");
+ expect(GLOBAL_SETTINGS_KEYS).not.toContain("allowAbsoluteFileBrowserPaths");
+ });
+
it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5);
expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true);
diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts
index 8a5c53d4af..7d24e41f1a 100644
--- a/packages/core/src/settings-schema.ts
+++ b/packages/core/src/settings-schema.ts
@@ -283,6 +283,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [],
+ /*
+ FNXC:FileBrowser 2026-06-29-00:00:
+ Absolute file-browser paths are disabled unless a project explicitly opts in, preserving the workspace boundary for normal installs while allowing local admin browsing through the same file-size, binary, type, and permission checks.
+ */
+ allowAbsoluteFileBrowserPaths: false,
autoMerge: true,
planApprovalMode: "workflow",
// U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` —
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 19ecca6d56..6ddcd6c0dc 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -3562,6 +3562,11 @@ export interface ProjectSettings {
* When set, matching paths are excluded from overlap checks for both
* active in-progress tasks and in-review tasks with unmerged worktrees. */
overlapIgnorePaths?: string[];
+ /**
+ * FNXC:FileBrowser 2026-06-29-00:00:
+ * Project owners can opt the workspace file browser into slash-prefixed absolute paths for local admin workflows. Default false keeps browsing confined to the selected project/task workspace; this does not apply to task-local file APIs, memory, plugin bundles, worktree-copy validation, or Windows drive-letter paths.
+ */
+ allowAbsoluteFileBrowserPaths?: boolean;
autoMerge: boolean;
/** When true, force every AI lane onto the deterministic mock provider regardless
* of per-task or per-lane overrides. No network calls, zero token cost. */
diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx
index fd9cdd8df7..9409029b26 100644
--- a/packages/dashboard/app/__tests__/settings-sections.test.tsx
+++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx
@@ -14,6 +14,7 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
+import { GeneralSection } from "../components/settings/sections/GeneralSection";
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
@@ -29,6 +30,17 @@ vi.mock("../components/AgentPromptsManager", () => ({
vi.mock("../components/SecretsView", () => ({
SecretsView: () =>
,
}));
+vi.mock("../api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchWorkflows: vi.fn(async () => []),
+ fetchWorkflow: vi.fn(async () => ({ id: "builtin:coding", name: "Coding" })),
+ fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
+ setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
+ fetchGlobalSettings: vi.fn(async () => ({})),
+ };
+});
expect.extend(jestDomMatchers);
afterEach(() => cleanup());
@@ -65,6 +77,35 @@ describe("AppearanceSection", () => {
});
});
+describe("GeneralSection", () => {
+ it("emits the absolute file-browser path toggle via setForm", () => {
+ function GeneralHost() {
+ const [form, setForm] = useState({ allowAbsoluteFileBrowserPaths: false } as SettingsFormState);
+ return (
+
+ );
+ }
+
+ render( );
+
+ const checkbox = screen.getByLabelText(/Allow absolute file-browser paths/i) as HTMLInputElement;
+ expect(checkbox.checked).toBe(false);
+ fireEvent.click(checkbox);
+
+ expect(checkbox.checked).toBe(true);
+ });
+});
+
describe("NotificationsSection", () => {
it("emits the chosen failure-notification mode via setForm", () => {
const setForm = vi.fn();
diff --git a/packages/dashboard/app/components/FileBrowser.tsx b/packages/dashboard/app/components/FileBrowser.tsx
index 6a18d67614..b67323d141 100644
--- a/packages/dashboard/app/components/FileBrowser.tsx
+++ b/packages/dashboard/app/components/FileBrowser.tsx
@@ -39,9 +39,29 @@ function formatTime(mtime?: string): string {
/** Build the full relative path for a file/directory entry */
function entryPath(currentPath: string, name: string): string {
+ if (currentPath.startsWith("/")) {
+ const base = currentPath === "/" ? "" : currentPath.replace(/\/+$/g, "");
+ return `${base}/${name}`;
+ }
return joinDisplayPath(currentPath, name);
}
+/*
+FNXC:FileBrowser 2026-06-29-00:00:
+When the project opts into absolute slash-prefixed browsing, Up navigation must preserve filesystem-root semantics instead of collapsing `/etc` to the workspace-relative root marker.
+*/
+function parentPath(currentPath: string): string {
+ if (currentPath === "/") {
+ return "/";
+ }
+ if (currentPath.startsWith("/")) {
+ const trimmed = currentPath.replace(/\/+$/g, "");
+ const index = trimmed.lastIndexOf("/");
+ return index <= 0 ? "/" : trimmed.slice(0, index);
+ }
+ return getParentDisplayPath(currentPath);
+}
+
// ── Context Menu State ──────────────────────────────────────────────────
interface ContextMenuState {
@@ -537,13 +557,13 @@ export function FileBrowser({
await deleteFile(workspace, dialog.entryFullPath, projectId);
break;
case "create-file": {
- const newFilePath = joinDisplayPath(dialog.entryFullPath, value);
+ const newFilePath = entryPath(dialog.entryFullPath, value);
await createWorkspaceFile(workspace, newFilePath, projectId);
onSelectFile(newFilePath);
break;
}
case "create-folder":
- await createWorkspaceDirectory(workspace, joinDisplayPath(dialog.entryFullPath, value), projectId);
+ await createWorkspaceDirectory(workspace, entryPath(dialog.entryFullPath, value), projectId);
break;
}
@@ -605,7 +625,7 @@ export function FileBrowser({
{
- onNavigate(getParentDisplayPath(currentPath));
+ onNavigate(parentPath(currentPath));
}}
>
diff --git a/packages/dashboard/app/components/FileBrowserModal.tsx b/packages/dashboard/app/components/FileBrowserModal.tsx
index 547e29ba22..593d9cc847 100644
--- a/packages/dashboard/app/components/FileBrowserModal.tsx
+++ b/packages/dashboard/app/components/FileBrowserModal.tsx
@@ -44,6 +44,13 @@ function isBinaryFile(filename: string): boolean {
function getParentDirectory(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
const lastSlash = normalized.lastIndexOf("/");
+ /*
+ FNXC:FileBrowser 2026-06-29-21:30:
+ Initial absolute files in the filesystem root, such as `/README.md`, must reopen the browser at `/` instead of workspace root. The absolute-path setting is slash-prefixed only, so preserve POSIX root semantics here without adding Windows drive-letter behavior.
+ */
+ if (normalized.startsWith("/") && lastSlash === 0) {
+ return "/";
+ }
return lastSlash > 0 ? normalized.slice(0, lastSlash) : ".";
}
diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx
index d6a7e9e462..a1b96bca17 100644
--- a/packages/dashboard/app/components/SettingsModal.tsx
+++ b/packages/dashboard/app/components/SettingsModal.tsx
@@ -72,6 +72,10 @@ const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
+function isSlashPrefixedAbsolutePath(path: string): boolean {
+ return path.startsWith("/");
+}
+
function DiscordIcon({ size = 13 }: { size?: number }) {
return (
{
if (overlapPathPickerIndex === null) return;
+ /*
+ FNXC:FileBrowserAbsolutePaths 2026-06-29-00:00:
+ The project-level absolute file-browser setting must not widen settings fields whose saved values are consumed as project-relative patterns. Reject slash-prefixed picker selections at the form boundary so overlapIgnorePaths cannot persist filesystem-absolute paths.
+ */
+ if (isSlashPrefixedAbsolutePath(path)) return;
+
setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
? [...f.overlapIgnorePaths]
@@ -2285,12 +2301,16 @@ export function SettingsModal({
}, []);
const selectWorktreesDirFromPicker = useCallback((path: string) => {
+ if (isSlashPrefixedAbsolutePath(path)) return;
+
const normalizedPath = path.endsWith("/") ? path : `${path}/`;
setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
closeWorktreesDirPicker();
}, [closeWorktreesDirPicker]);
const selectCurrentWorktreesDir = useCallback(() => {
+ if (isSlashPrefixedAbsolutePath(worktreesDirPickerCurrentPath)) return;
+
const normalizedPath = worktreesDirPickerCurrentPath === "."
? "./"
: (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`);
@@ -2316,6 +2336,12 @@ export function SettingsModal({
const selectWorktreeCopyFile = useCallback((path: string) => {
if (worktreeCopyFilePickerIndex === null) return;
+ /*
+ FNXC:FileBrowserAbsolutePaths 2026-06-29-00:00:
+ worktreeCopyFiles are copied from the project workspace into task worktrees. Keep this picker project-relative even when the standalone Files browser can browse slash-prefixed absolute paths.
+ */
+ if (isSlashPrefixedAbsolutePath(path)) return;
+
setForm((f) => {
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
? [...f.worktreeCopyFiles]
diff --git a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
index 6f8f632316..8881c259db 100644
--- a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
@@ -213,6 +213,24 @@ describe("FileBrowserModal", () => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", true, undefined);
});
+ it("opens root-level absolute initial files at filesystem root", async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText("/README.md").length).toBeGreaterThan(0);
+ });
+
+ expect(mockSetPath).toHaveBeenCalledWith("/");
+ expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "/README.md", true, undefined);
+ });
+
it("switches workspace and notifies parent", async () => {
const user = userEvent.setup();
render(
diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx
index 18b489e35e..242b21429a 100644
--- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx
+++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx
@@ -234,6 +234,23 @@ describe("SettingsModal", () => {
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
+ it("keeps settings file pickers workspace-confined even when absolute browsing exists", async () => {
+ renderModal({ initialSection: "worktrees" });
+ await waitForSettingsModalReady();
+
+ expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith(
+ "project",
+ expect.any(Boolean),
+ undefined,
+ { allowAbsolutePaths: false },
+ );
+ expect(mockUseWorkspaceFileBrowser.mock.calls.filter((call) => call[0] === "project")).toEqual(
+ expect.arrayContaining([
+ ["project", false, undefined, { allowAbsolutePaths: false }],
+ ]),
+ );
+ });
+
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" (SettingsView) was a zero-coverage branch. Assert the embedded contract via
// useEmbeddedPresentation: embedded root class present, region role (not dialog), no fixed .modal-overlay
diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx
index 0085550892..b9f51bcdb5 100644
--- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx
@@ -106,6 +106,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
setForm((f) => ({ ...f, workspaceMode: e.target.checked }))}/>{t("settings.general.workspaceMode", " Workspace mode (multi-repo) ")}
{t("settings.general.workspaceModeHint", "When enabled, the project root is treated as a workspace containing multiple git sub-repos. Tasks run per-sub-repo and no git repo is created at the root. Disable for single-repo projects.")}
+ {/*
+ FNXC:FileBrowser 2026-06-29-00:00:
+ This project-scoped General toggle is intentionally default-off because slash-prefixed file-browser paths can browse outside the workspace. It only affects workspace file-browser routes and keeps task-local file APIs and other path validators confined.
+ */}
+
+
+ setForm((f) => ({ ...f, allowAbsoluteFileBrowserPaths: e.target.checked }))}/>{t("settings.general.allowAbsoluteFileBrowserPaths", " Allow absolute file-browser paths ")}
+ {t("settings.general.allowAbsoluteFileBrowserPathsHint", "When enabled, slash-prefixed paths such as /tmp can be opened in the workspace file browser. Windows drive-letter paths remain blocked, and other path validators are unchanged.")}
+
{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}
setForm((f) => ({
diff --git a/packages/dashboard/app/hooks/__tests__/useWorkspaceFileBrowser.test.ts b/packages/dashboard/app/hooks/__tests__/useWorkspaceFileBrowser.test.ts
index f80e511156..fee9b3109b 100644
--- a/packages/dashboard/app/hooks/__tests__/useWorkspaceFileBrowser.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useWorkspaceFileBrowser.test.ts
@@ -72,6 +72,24 @@ describe("useWorkspaceFileBrowser", () => {
expect(result.current.entries).toEqual([]);
});
+ it("keeps settings pickers project-relative when absolute browsing is disabled", async () => {
+ mockFetchWorkspaceFileList.mockResolvedValue({ path: ".", entries: [] });
+
+ const { result } = renderHook(() => (
+ useWorkspaceFileBrowser("project", true, undefined, { allowAbsolutePaths: false })
+ ));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ act(() => {
+ result.current.setPath("/outside/project");
+ });
+
+ expect(result.current.currentPath).toBe(".");
+ expect(result.current.error).toBe("This picker only accepts project-relative paths");
+ expect(mockFetchWorkspaceFileList).toHaveBeenCalledTimes(1);
+ });
+
it("returns hidden files and directories from the API response", async () => {
const mockResponse: FileListResponse = {
path: ".",
diff --git a/packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts b/packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
index 062beadfa6..fbade5161b 100644
--- a/packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
+++ b/packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
@@ -12,6 +12,14 @@ interface UseWorkspaceFileBrowserReturn {
refresh: () => void;
}
+interface UseWorkspaceFileBrowserOptions {
+ allowAbsolutePaths?: boolean;
+}
+
+function isSlashPrefixedAbsolutePath(path: string): boolean {
+ return path.startsWith("/");
+}
+
/**
* Hook for browsing files in a selected workspace.
*
@@ -23,25 +31,35 @@ export function useWorkspaceFileBrowser(
workspace: string,
enabled: boolean,
projectId?: string,
+ options: UseWorkspaceFileBrowserOptions = {},
): UseWorkspaceFileBrowserReturn {
const [entries, setEntries] = useState([]);
const [currentPath, setCurrentPath] = useState(".");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
+ const allowAbsolutePaths = options.allowAbsolutePaths !== false;
const refresh = useCallback(() => {
setRefreshKey((key) => key + 1);
}, []);
const setPath = useCallback((path: string) => {
+ if (!allowAbsolutePaths && isSlashPrefixedAbsolutePath(path)) {
+ setError("This picker only accepts project-relative paths");
+ return;
+ }
+
setCurrentPath(path);
setError(null);
- }, []);
+ }, [allowAbsolutePaths]);
/*
FNXC:FileBrowser 2026-06-29-19:35:
Workspace file pickers must start each workspace at root so SettingsModal directory/file pickers do not inherit editor selection state. FileBrowserModal restores its selected file path at the modal layer when it needs editor persistence across worktree switches.
+
+ FNXC:FileBrowserAbsolutePaths 2026-06-29-00:00:
+ Settings-modal path pickers save project-relative contracts for overlap ignore paths and worktree copy files. Keep absolute browsing opt-in at the top-level file browser by letting callers reject slash-prefixed navigation before it reaches settings form state.
*/
useEffect(() => {
setCurrentPath(".");
diff --git a/packages/dashboard/src/__tests__/file-service.test.ts b/packages/dashboard/src/__tests__/file-service.test.ts
index 02fb5e5547..a868b27169 100644
--- a/packages/dashboard/src/__tests__/file-service.test.ts
+++ b/packages/dashboard/src/__tests__/file-service.test.ts
@@ -13,6 +13,7 @@ import {
searchWorkspaceFiles,
listProjectMarkdownFiles,
scanMarkdownFiles,
+ createWorkspaceDirectory,
copyWorkspaceFile,
moveWorkspaceFile,
deleteWorkspaceFile,
@@ -104,16 +105,27 @@ describe("MAX_FILE_SIZE", () => {
describe("path traversal protection", () => {
const mockGetTask = vi.fn();
const mockGetRootDir = vi.fn();
+ const mockGetSettings = vi.fn();
const mockStore = {
getTask: mockGetTask,
getRootDir: mockGetRootDir,
+ getSettings: mockGetSettings,
} as unknown as TaskStore;
beforeEach(() => {
mockGetTask.mockReset();
mockGetRootDir.mockReset();
+ mockGetSettings.mockReset();
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: false });
mockStat.mockReset();
mockReaddir.mockReset();
+ mockReadFile.mockReset();
+ mockWriteFile.mockReset();
+ mockCopyFile.mockReset();
+ mockRename.mockReset();
+ mockRm.mockReset();
+ mockMkdir.mockReset();
+ mockAccess.mockReset();
mockExistsSync.mockReset();
});
@@ -139,11 +151,16 @@ describe("path traversal protection", () => {
await expect(listProjectFiles(mockStore, "file\0.txt")).rejects.toThrow("Invalid characters");
});
- it("rejects URL-encoded path traversal", async () => {
+ it("treats percent-encoded traversal text as a literal already-decoded path", async () => {
mockGetRootDir.mockReturnValue("/test/project");
+ mockStat.mockResolvedValue({ isDirectory: () => true, isFile: () => false });
+ mockReaddir.mockResolvedValue([]);
- await expect(listProjectFiles(mockStore, "%2e%2e%2fsecret.txt")).rejects.toThrow(FileServiceError);
- await expect(listProjectFiles(mockStore, "%2e%2e%2fsecret.txt")).rejects.toThrow("Path traversal detected");
+ await expect(listProjectFiles(mockStore, "%2e%2e%2fsecret.txt")).resolves.toMatchObject({
+ path: "%2e%2e%2fsecret.txt",
+ entries: [],
+ });
+ expect(mockStat).toHaveBeenCalledWith("/test/project/%2e%2e%2fsecret.txt");
});
});
@@ -246,6 +263,177 @@ describe("path traversal protection", () => {
await expect(readWorkspaceFile(mockStore, "project", "/etc/passwd")).rejects.toThrow(FileServiceError);
});
+
+ it("allows slash-prefixed workspace file reads when the project setting is enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockStat.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ size: 12,
+ mtime: new Date("2026-06-29T00:00:00.000Z"),
+ });
+ mockReadFile.mockResolvedValue("absolute data");
+
+ const result = await readWorkspaceFile(mockStore, "project", "/tmp/file.txt");
+
+ expect(result.content).toBe("absolute data");
+ expect(mockStat).toHaveBeenCalledWith("/tmp/file.txt");
+ });
+
+ it("allows slash-prefixed workspace directory listing when the project setting is enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockStat
+ .mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false })
+ .mockResolvedValueOnce({
+ isDirectory: () => false,
+ isFile: () => true,
+ size: 42,
+ mtime: new Date("2026-06-29T00:00:00.000Z"),
+ });
+ mockReaddir.mockResolvedValue([{ name: "file.txt", isDirectory: () => false, isFile: () => true }]);
+
+ const result = await listWorkspaceFiles(mockStore, "project", "/tmp");
+
+ expect(result.path).toBe("/tmp");
+ expect(result.entries).toEqual(expect.arrayContaining([expect.objectContaining({ name: "file.txt" })]));
+ });
+
+ it("allows slash-prefixed workspace reads for task worktree browsers when the project setting is enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetTask.mockResolvedValue({ id: "FN-123", worktree: "/project/.worktrees/FN-123" });
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockAccess.mockResolvedValue(undefined);
+ mockStat.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ size: 12,
+ mtime: new Date("2026-06-29T00:00:00.000Z"),
+ });
+ mockReadFile.mockResolvedValue("absolute task browser data");
+
+ const result = await readWorkspaceFile(mockStore, "FN-123", "/tmp/file.txt");
+
+ expect(result.content).toBe("absolute task browser data");
+ expect(mockAccess).toHaveBeenCalledWith("/project/.worktrees/FN-123");
+ expect(mockStat).toHaveBeenCalledWith("/tmp/file.txt");
+ });
+
+ it("keeps project and task file APIs confined even when absolute file-browser paths are enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+
+ await expect(readProjectFile(mockStore, "/tmp/file.txt")).rejects.toThrow("Absolute paths not allowed");
+ await expect(readFile(mockStore, "FN-123", "/tmp/file.txt")).rejects.toThrow("Absolute paths not allowed");
+ });
+
+ it("keeps Windows drive-letter paths blocked even when absolute file-browser paths are enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+
+ await expect(readWorkspaceFile(mockStore, "project", "C:/Users/name/file.txt")).rejects.toThrow("Absolute paths not allowed");
+ });
+
+ it("keeps percent-escaped text literal instead of decoding it into an absolute path", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockStat.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ size: 12,
+ mtime: new Date("2026-06-29T00:00:00.000Z"),
+ });
+ mockReadFile.mockResolvedValue("literal percent data");
+
+ const result = await readWorkspaceFile(mockStore, "project", "%2Ftmp%2Fx");
+
+ expect(result.content).toBe("literal percent data");
+ expect(mockStat).toHaveBeenCalledWith("/project/%2Ftmp%2Fx");
+ expect(mockReadFile).toHaveBeenCalledWith("/project/%2Ftmp%2Fx", "utf-8");
+ });
+
+ it("keeps malformed percent-encoded text literal in file-service paths", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockStat.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ size: 12,
+ mtime: new Date("2026-06-29T00:00:00.000Z"),
+ });
+ mockReadFile.mockResolvedValue("malformed literal data");
+
+ await expect(readWorkspaceFile(mockStore, "project", "%E0%A4%A")).resolves.toMatchObject({
+ content: "malformed literal data",
+ });
+ expect(mockStat).toHaveBeenCalledWith("/project/%E0%A4%A");
+ });
+
+ it("allows slash-prefixed workspace writes and directory creation when the project setting is enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockStat
+ .mockRejectedValueOnce({ code: "ENOENT" })
+ .mockResolvedValueOnce({ isDirectory: () => true })
+ .mockResolvedValueOnce({ size: 7, mtime: new Date("2026-06-29T00:00:00.000Z") })
+ .mockRejectedValueOnce({ code: "ENOENT" })
+ .mockResolvedValueOnce({ isDirectory: () => true });
+ mockWriteFile.mockResolvedValue(undefined);
+ mockMkdir.mockResolvedValue(undefined);
+
+ await expect(writeWorkspaceFile(mockStore, "project", "/tmp/file.txt", "content")).resolves.toMatchObject({ success: true, size: 7 });
+ await expect(createWorkspaceDirectory(mockStore, "project", "/tmp/new-dir")).resolves.toMatchObject({ success: true, path: "/tmp/new-dir" });
+ expect(mockWriteFile).toHaveBeenCalledWith("/tmp/file.txt", "content", "utf-8");
+ expect(mockMkdir).toHaveBeenCalledWith("/tmp/new-dir");
+ });
+
+ it("allows slash-prefixed workspace copy, move, delete, and rename operations when the project setting is enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ mockStat
+ .mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false })
+ .mockRejectedValueOnce({ code: "ENOENT" })
+ .mockResolvedValueOnce({ isDirectory: () => true })
+ .mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false })
+ .mockRejectedValueOnce({ code: "ENOENT" })
+ .mockResolvedValueOnce({ isDirectory: () => true })
+ .mockResolvedValueOnce({ isDirectory: () => false })
+ .mockResolvedValueOnce({ isDirectory: () => false })
+ .mockRejectedValueOnce({ code: "ENOENT" });
+ mockCopyFile.mockResolvedValue(undefined);
+ mockRename.mockResolvedValue(undefined);
+ mockRm.mockResolvedValue(undefined);
+
+ await expect(copyWorkspaceFile(mockStore, "project", "/tmp/source.txt", "/tmp/copy.txt")).resolves.toMatchObject({ success: true });
+ await expect(moveWorkspaceFile(mockStore, "project", "/tmp/source.txt", "/tmp/moved.txt")).resolves.toMatchObject({ success: true });
+ await expect(deleteWorkspaceFile(mockStore, "project", "/tmp/old.txt")).resolves.toMatchObject({ success: true });
+ await expect(renameWorkspaceFile(mockStore, "project", "/tmp/name.txt", "renamed.txt")).resolves.toMatchObject({ success: true });
+ expect(mockCopyFile).toHaveBeenCalledWith("/tmp/source.txt", "/tmp/copy.txt");
+ expect(mockRename).toHaveBeenCalledWith("/tmp/source.txt", "/tmp/moved.txt");
+ expect(mockRm).toHaveBeenCalledWith("/tmp/old.txt");
+ expect(mockRename).toHaveBeenCalledWith("/tmp/name.txt", "/tmp/renamed.txt");
+ });
+
+ it("allows slash-prefixed workspace file and folder downloads when the project setting is enabled", async () => {
+ mockGetRootDir.mockReturnValue("/project");
+ mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: true });
+ const fileMtime = new Date("2026-06-29T00:00:00.000Z");
+ mockStat
+ .mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false, size: 11, mtime: fileMtime })
+ .mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true });
+
+ await expect(getWorkspaceFileForDownload(mockStore, "project", "/tmp/file.txt")).resolves.toMatchObject({
+ absolutePath: "/tmp/file.txt",
+ fileName: "file.txt",
+ stats: { size: 11, mtime: fileMtime, isFile: true },
+ });
+ await expect(getWorkspaceFolderForZip(mockStore, "project", "/tmp/folder")).resolves.toMatchObject({
+ absolutePath: "/tmp/folder",
+ dirName: "folder",
+ });
+ });
});
describe("complex path traversal patterns", () => {
@@ -833,7 +1021,7 @@ describe("URL-encoded characters handling", () => {
mockStat.mockReset();
});
- it("decodes URL-encoded characters safely in file paths", async () => {
+ it("keeps percent-encoded characters literal in file-service paths", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({
isFile: () => true,
@@ -844,9 +1032,8 @@ describe("URL-encoded characters handling", () => {
await readProjectFile(mockStore, "file%20name.txt");
- // Should decode %20 to space and look for the file
expect(mockReadFile).toHaveBeenCalledWith(
- "/test/project/file name.txt",
+ "/test/project/file%20name.txt",
"utf-8",
);
});
diff --git a/packages/dashboard/src/file-service.ts b/packages/dashboard/src/file-service.ts
index 4130a4154b..e87e4e0511 100644
--- a/packages/dashboard/src/file-service.ts
+++ b/packages/dashboard/src/file-service.ts
@@ -1,7 +1,7 @@
import { join, resolve, relative, dirname, basename } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, copyFile as fsCopyFile, rename as fsRename, rm as fsRm, mkdir, access } from "node:fs/promises";
import type { Dirent } from "node:fs";
-import type { TaskStore } from "@fusion/core";
+import type { ProjectSettings, TaskStore } from "@fusion/core";
/**
* File node type representing a file or directory entry.
@@ -122,23 +122,39 @@ async function getWorkspaceBasePath(store: TaskStore, workspace: WorkspaceId): P
* Validate and resolve a file path to ensure it stays within the allowed directory.
* Prevents directory traversal attacks.
*/
-function validatePath(basePath: string, filePath: string): string {
+interface PathValidationOptions {
+ allowAbsolutePaths?: boolean;
+}
+
+/*
+FNXC:FileBrowserAbsolutePaths 2026-06-29-18:42:
+Absolute slash-prefixed paths are a project-scoped file-browser escape hatch only. The default remains workspace-confined, Windows drive-letter paths stay rejected, and all callers keep traversal/null-byte/type/permission checks after resolution.
+
+FNXC:FileBrowserAbsolutePaths 2026-06-29-21:04:
+File-service callers pass already-decoded filesystem path strings. Do not decode percent escapes here: literal `%2F` and `100%` are valid filename text, and HTTP-layer decoding must not be repeated into a new absolute path.
+*/
+function validatePath(basePath: string, filePath: string, options: PathValidationOptions = {}): string {
// Reject paths with null bytes
if (filePath.includes("\0")) {
throw new FileServiceError(`Access denied: Invalid characters in path`, "EINVAL");
}
- // Decode URL-encoded characters for security check
- const decodedPath = decodeURIComponent(filePath);
+ if (filePath.match(/^[a-zA-Z]:/)) {
+ throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
+ }
+
+ if (filePath.startsWith("/") && options.allowAbsolutePaths === true) {
+ return resolve(filePath);
+ }
// Reject absolute paths
- if (decodedPath.startsWith("/") || decodedPath.match(/^[a-zA-Z]:/)) {
+ if (filePath.startsWith("/")) {
throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
}
// Resolve the path against base path
const resolvedBase = resolve(basePath);
- const resolvedPath = resolve(join(resolvedBase, decodedPath));
+ const resolvedPath = resolve(join(resolvedBase, filePath));
// Ensure the resolved path is within the base path
const relativePath = relative(resolvedBase, resolvedPath);
@@ -156,8 +172,23 @@ function validatePath(basePath: string, filePath: string): string {
return resolvedPath;
}
-async function listFilesForBasePath(basePath: string, subPath?: string): Promise {
- const targetPath = subPath ? validatePath(basePath, subPath) : basePath;
+function isOutsideBase(basePath: string, resolvedPath: string): boolean {
+ const resolvedBase = resolve(basePath);
+ const relativePath = relative(resolvedBase, resolvedPath);
+ return relativePath.startsWith("..") || relativePath === "..";
+}
+
+function isFilesystemRoot(resolvedPath: string): boolean {
+ return dirname(resolvedPath) === resolvedPath;
+}
+
+async function getWorkspacePathValidationOptions(store: TaskStore): Promise {
+ const settings = await store.getSettings?.() as Pick | undefined;
+ return { allowAbsolutePaths: settings?.allowAbsoluteFileBrowserPaths === true };
+}
+
+async function listFilesForBasePath(basePath: string, subPath?: string, options: PathValidationOptions = {}): Promise {
+ const targetPath = subPath ? validatePath(basePath, subPath, options) : basePath;
let stats;
try {
@@ -201,7 +232,7 @@ async function listFilesForBasePath(basePath: string, subPath?: string): Promise
const relativeBase = relative(basePath, targetPath);
return {
- path: relativeBase || ".",
+ path: options.allowAbsolutePaths === true && isOutsideBase(basePath, targetPath) ? targetPath : (relativeBase || "."),
entries: fileNodes,
};
} catch (err: unknown) {
@@ -216,12 +247,12 @@ async function listFilesForBasePath(basePath: string, subPath?: string): Promise
}
}
-async function readFileForBasePath(basePath: string, filePath: string): Promise {
+async function readFileForBasePath(basePath: string, filePath: string, options: PathValidationOptions = {}): Promise {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
- const resolvedPath = validatePath(basePath, filePath);
+ const resolvedPath = validatePath(basePath, filePath, options);
let stats;
try {
@@ -262,7 +293,7 @@ async function readFileForBasePath(basePath: string, filePath: string): Promise<
}
}
-async function writeFileForBasePath(basePath: string, filePath: string, content: string): Promise {
+async function writeFileForBasePath(basePath: string, filePath: string, content: string, options: PathValidationOptions = {}): Promise {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
@@ -272,7 +303,7 @@ async function writeFileForBasePath(basePath: string, filePath: string, content:
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
}
- const resolvedPath = validatePath(basePath, filePath);
+ const resolvedPath = validatePath(basePath, filePath, options);
try {
const stats = await stat(resolvedPath);
@@ -438,7 +469,8 @@ export async function listWorkspaceFiles(
subPath?: string,
): Promise {
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- return listFilesForBasePath(workspaceBase, subPath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ return listFilesForBasePath(workspaceBase, subPath, pathOptions);
}
/**
@@ -450,7 +482,8 @@ export async function readWorkspaceFile(
filePath: string,
): Promise {
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- return readFileForBasePath(workspaceBase, filePath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ return readFileForBasePath(workspaceBase, filePath, pathOptions);
}
/**
@@ -463,7 +496,8 @@ export async function writeWorkspaceFile(
content: string,
): Promise {
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- return writeFileForBasePath(workspaceBase, filePath, content);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ return writeFileForBasePath(workspaceBase, filePath, content, pathOptions);
}
// ── Workspace File Operations (Create, Copy, Move, Delete, Rename) ─────────
@@ -487,7 +521,8 @@ export async function createWorkspaceDirectory(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const resolvedPath = validatePath(workspaceBase, dirPath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const resolvedPath = validatePath(workspaceBase, dirPath, pathOptions);
try {
await stat(resolvedPath);
@@ -536,13 +571,13 @@ export async function createWorkspaceDirectory(
* Validate that both source and destination paths are within the allowed workspace.
* Prevents copying/moving files outside the workspace boundary.
*/
-function validateSourceAndDestination(basePath: string, sourcePath: string, destinationPath: string): { resolvedSource: string; resolvedDest: string } {
- const resolvedSource = validatePath(basePath, sourcePath);
- const resolvedDest = validatePath(basePath, destinationPath);
+function validateSourceAndDestination(basePath: string, sourcePath: string, destinationPath: string, options: PathValidationOptions = {}): { resolvedSource: string; resolvedDest: string } {
+ const resolvedSource = validatePath(basePath, sourcePath, options);
+ const resolvedDest = validatePath(basePath, destinationPath, options);
- // Prevent operating on the workspace root itself
+ // Prevent operating on the workspace root itself, or filesystem root when the absolute-path escape hatch is enabled.
const sourceRelative = relative(resolve(basePath), resolvedSource);
- if (!sourceRelative || sourceRelative === "." || sourceRelative === "") {
+ if (!sourceRelative || sourceRelative === "." || sourceRelative === "" || isFilesystemRoot(resolvedSource)) {
throw new FileServiceError("Cannot operate on workspace root directory", "EINVAL");
}
@@ -573,7 +608,8 @@ export async function copyWorkspaceFile(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath, pathOptions);
// Verify source exists
let sourceStats;
@@ -654,7 +690,8 @@ export async function moveWorkspaceFile(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath, pathOptions);
// Verify source exists
try {
@@ -731,11 +768,12 @@ export async function deleteWorkspaceFile(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const resolvedPath = validatePath(workspaceBase, filePath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const resolvedPath = validatePath(workspaceBase, filePath, pathOptions);
- // Prevent operating on the workspace root itself
+ // Prevent operating on the workspace root itself, or filesystem root when the absolute-path escape hatch is enabled.
const relativePath = relative(resolve(workspaceBase), resolvedPath);
- if (!relativePath || relativePath === "." || relativePath === "") {
+ if (!relativePath || relativePath === "." || relativePath === "" || isFilesystemRoot(resolvedPath)) {
throw new FileServiceError("Cannot delete workspace root directory", "EINVAL");
}
@@ -797,11 +835,12 @@ export async function renameWorkspaceFile(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const resolvedPath = validatePath(workspaceBase, filePath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const resolvedPath = validatePath(workspaceBase, filePath, pathOptions);
- // Prevent operating on the workspace root itself
+ // Prevent operating on the workspace root itself, or filesystem root when the absolute-path escape hatch is enabled.
const relativePath = relative(resolve(workspaceBase), resolvedPath);
- if (!relativePath || relativePath === "." || relativePath === "") {
+ if (!relativePath || relativePath === "." || relativePath === "" || isFilesystemRoot(resolvedPath)) {
throw new FileServiceError("Cannot rename workspace root directory", "EINVAL");
}
@@ -819,14 +858,16 @@ export async function renameWorkspaceFile(
// Build destination path by replacing the basename
const destPath = join(dirname(resolvedPath), newName);
- // Validate destination stays within workspace
- const destRelative = relative(resolve(workspaceBase), destPath);
- if (destRelative.startsWith("..") || destRelative.startsWith("../") || destRelative === "..") {
- throw new FileServiceError("Destination would be outside workspace", "EINVAL");
- }
+ // Validate destination stays within workspace unless the source path was explicitly allowed as an absolute file-browser path.
+ if (pathOptions.allowAbsolutePaths !== true || !filePath.startsWith("/")) {
+ const destRelative = relative(resolve(workspaceBase), destPath);
+ if (destRelative.startsWith("..") || destRelative.startsWith("../") || destRelative === "..") {
+ throw new FileServiceError("Destination would be outside workspace", "EINVAL");
+ }
- if (!destPath.startsWith(resolve(workspaceBase))) {
- throw new FileServiceError("Destination would be outside workspace", "EINVAL");
+ if (!destPath.startsWith(resolve(workspaceBase))) {
+ throw new FileServiceError("Destination would be outside workspace", "EINVAL");
+ }
}
// Check destination doesn't already exist
@@ -872,11 +913,12 @@ export async function getWorkspaceFileForDownload(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const resolvedPath = validatePath(workspaceBase, filePath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const resolvedPath = validatePath(workspaceBase, filePath, pathOptions);
- // Prevent downloading the workspace root itself (it's not a file)
+ // Prevent downloading the workspace root itself, or filesystem root when the absolute-path escape hatch is enabled.
const relativePath = relative(resolve(workspaceBase), resolvedPath);
- if (!relativePath || relativePath === "." || relativePath === "") {
+ if (!relativePath || relativePath === "." || relativePath === "" || isFilesystemRoot(resolvedPath)) {
throw new FileServiceError("Cannot download workspace root", "EINVAL");
}
@@ -925,11 +967,12 @@ export async function getWorkspaceFolderForZip(
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
- const resolvedPath = validatePath(workspaceBase, dirPath);
+ const pathOptions = await getWorkspacePathValidationOptions(store);
+ const resolvedPath = validatePath(workspaceBase, dirPath, pathOptions);
- // Prevent downloading the workspace root as ZIP (too broad)
+ // Prevent downloading the workspace root as ZIP, or filesystem root when the absolute-path escape hatch is enabled.
const relativePath = relative(resolve(workspaceBase), resolvedPath);
- if (!relativePath || relativePath === "." || relativePath === "") {
+ if (!relativePath || relativePath === "." || relativePath === "" || isFilesystemRoot(resolvedPath)) {
throw new FileServiceError("Cannot download workspace root as ZIP", "EINVAL");
}