FN-7264: add opt-in absolute file-browser paths

Add an opt-in project setting that lets the workspace file browser use slash-prefixed absolute paths while preserving existing confined defaults.

- Add the allowAbsoluteFileBrowserPaths project setting, docs, and changeset release note.
- Thread absolute-path validation through workspace file-browser listing, editing, file operations, and downloads without widening other path APIs.
- Add Settings UI controls and keep settings-specific file pickers project-relative.
- Cover absolute-path browsing, percent-literal paths, root navigation, and settings picker behavior with tests.

Files changed:
 .changeset/fn-7264-absolute-file-browser-paths.md  |   7 +
 docs/settings-reference.md                         |   3 +
 .../core/src/__tests__/settings-parity.test.ts     |   8 +
 packages/core/src/settings-schema.ts               |   5 +
 packages/core/src/types.ts                         |   5 +
 .../app/__tests__/settings-sections.test.tsx       |  41 +++++
 packages/dashboard/app/components/FileBrowser.tsx  |  26 ++-
 .../dashboard/app/components/FileBrowserModal.tsx  |   7 +
 .../dashboard/app/components/SettingsModal.tsx     |  34 +++-
 .../components/__tests__/FileBrowserModal.test.tsx |  18 ++
 .../__tests__/SettingsModal.general.test.tsx       |  17 ++
 .../settings/sections/GeneralSection.tsx           |   9 +
 .../__tests__/useWorkspaceFileBrowser.test.ts      |  18 ++
 .../dashboard/app/hooks/useWorkspaceFileBrowser.ts |  20 ++-
 .../dashboard/src/__tests__/file-service.test.ts   | 199 ++++++++++++++++++++-
 packages/dashboard/src/file-service.ts             | 129 ++++++++-----
 16 files changed, 489 insertions(+), 57 deletions(-)

Fusion-Task-Id: FN-7264

Fusion-Task-Lineage: ec71facd-0345-4490-a3df-244b98e24c2d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 23:17:49 -07:00
parent 17fce43205
commit ea0707c573
16 changed files with 489 additions and 57 deletions

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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);

View File

@@ -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` —

View File

@@ -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. */

View File

@@ -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: () => <div data-testid="secrets-view" />,
}));
vi.mock("../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api")>();
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 (
<GeneralSection
scopeBanner={null}
form={form}
setForm={setForm}
addToast={vi.fn()}
prefixError={null}
setPrefixError={vi.fn()}
projectTrackingRepoOptions={[]}
projectTrackingRepoLoading={false}
projectTrackingRepoError={null}
/>
);
}
render(<GeneralHost />);
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();

View File

@@ -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({
<button
className="file-browser-up"
onClick={() => {
onNavigate(getParentDisplayPath(currentPath));
onNavigate(parentPath(currentPath));
}}
>
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />

View File

@@ -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) : ".";
}

View File

@@ -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 (
<svg
@@ -724,6 +728,7 @@ export function SettingsModal({
groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [],
allowAbsoluteFileBrowserPaths: false,
autoMerge: true,
planApprovalMode: "workflow",
mergeStrategy: "direct",
@@ -807,7 +812,7 @@ export function SettingsModal({
loading: overlapPathPickerLoading,
error: overlapPathPickerError,
refresh: refreshOverlapPathPicker,
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId, { allowAbsolutePaths: false });
const {
entries: worktreesDirPickerEntries,
@@ -816,7 +821,7 @@ export function SettingsModal({
loading: worktreesDirPickerLoading,
error: worktreesDirPickerError,
refresh: refreshWorktreesDirPicker,
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId);
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: false });
const {
entries: worktreeCopyFilePickerEntries,
@@ -825,7 +830,7 @@ export function SettingsModal({
loading: worktreeCopyFilePickerLoading,
error: worktreeCopyFilePickerError,
refresh: refreshWorktreeCopyFilePicker,
} = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId);
} = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId, { allowAbsolutePaths: false });
const { nodes } = useNodes();
const experimentalFeatures = form.experimentalFeatures ?? {};
@@ -995,6 +1000,7 @@ export function SettingsModal({
const normalizedSettings = {
...s,
ignoreHiddenOverlapPaths: s.ignoreHiddenOverlapPaths ?? true,
allowAbsoluteFileBrowserPaths: s.allowAbsoluteFileBrowserPaths === true,
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
@@ -2034,7 +2040,11 @@ export function SettingsModal({
setImportFile(null);
// Refresh settings to show imported values
const refreshed = await fetchSettings(projectId);
setForm({ ...refreshed, ignoreHiddenOverlapPaths: refreshed.ignoreHiddenOverlapPaths ?? true });
setForm({
...refreshed,
ignoreHiddenOverlapPaths: refreshed.ignoreHiddenOverlapPaths ?? true,
allowAbsoluteFileBrowserPaths: refreshed.allowAbsoluteFileBrowserPaths === true,
});
} else {
addToast(result.error || t("settings.importExport.importFailed", "Import failed"), "error");
}
@@ -2246,6 +2256,12 @@ export function SettingsModal({
const selectOverlapIgnorePath = useCallback((path: string) => {
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]

View File

@@ -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(
<FileBrowserModal
initialWorkspace="project"
initialFile="/README.md"
isOpen={true}
onClose={mockOnClose}
/>,
);
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(

View File

@@ -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

View File

@@ -106,6 +106,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
<input id="workspaceMode" type="checkbox" checked={form.workspaceMode === true} onChange={(e) => setForm((f) => ({ ...f, workspaceMode: e.target.checked }))}/>{t("settings.general.workspaceMode", " Workspace mode (multi-repo) ")}</label>
<small>{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.")}</small>
</div>
{/*
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.
*/}
<div className="form-group">
<label htmlFor="allowAbsoluteFileBrowserPaths" className="checkbox-label">
<input id="allowAbsoluteFileBrowserPaths" type="checkbox" checked={form.allowAbsoluteFileBrowserPaths === true} onChange={(e) => setForm((f) => ({ ...f, allowAbsoluteFileBrowserPaths: e.target.checked }))}/>{t("settings.general.allowAbsoluteFileBrowserPaths", " Allow absolute file-browser paths ")}</label>
<small>{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.")}</small>
</div>
<div className="form-group">
<label htmlFor="completionDocumentationMode">{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}</label>
<select id="completionDocumentationMode" value={form.completionDocumentationMode || "off"} onChange={(e) => setForm((f) => ({

View File

@@ -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: ".",

View File

@@ -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<FileNode[]>([]);
const [currentPath, setCurrentPath] = useState<string>(".");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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(".");

View File

@@ -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",
);
});

View File

@@ -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<FileListResponse> {
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<PathValidationOptions> {
const settings = await store.getSettings?.() as Pick<ProjectSettings, "allowAbsoluteFileBrowserPaths"> | undefined;
return { allowAbsolutePaths: settings?.allowAbsoluteFileBrowserPaths === true };
}
async function listFilesForBasePath(basePath: string, subPath?: string, options: PathValidationOptions = {}): Promise<FileListResponse> {
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<FileContentResponse> {
async function readFileForBasePath(basePath: string, filePath: string, options: PathValidationOptions = {}): Promise<FileContentResponse> {
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<SaveFileResponse> {
async function writeFileForBasePath(basePath: string, filePath: string, content: string, options: PathValidationOptions = {}): Promise<SaveFileResponse> {
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<FileListResponse> {
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<FileContentResponse> {
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<SaveFileResponse> {
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");
}