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`. 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, > **Moved keys retained for reference.** Some rows below — the step-execution,
> review/approval, and per-phase model-lane keys listed under > review/approval, and per-phase model-lane keys listed under
> [Where did my setting go?](#where-did-my-setting-go) — are no longer project > [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`. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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"); 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", () => { it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5); expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5);
expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true); expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true);

View File

@@ -283,6 +283,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
groupOverlappingFiles: true, groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true, ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [], 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, autoMerge: true,
planApprovalMode: "workflow", planApprovalMode: "workflow",
// U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` — // 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 * When set, matching paths are excluded from overlap checks for both
* active in-progress tasks and in-review tasks with unmerged worktrees. */ * active in-progress tasks and in-review tasks with unmerged worktrees. */
overlapIgnorePaths?: string[]; 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; autoMerge: boolean;
/** When true, force every AI lane onto the deterministic mock provider regardless /** 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. */ * 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 * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { AppearanceSection } from "../components/settings/sections/AppearanceSection"; import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
import { GeneralSection } from "../components/settings/sections/GeneralSection";
import { NotificationsSection } from "../components/settings/sections/NotificationsSection"; import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection"; import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub"; import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
@@ -29,6 +30,17 @@ vi.mock("../components/AgentPromptsManager", () => ({
vi.mock("../components/SecretsView", () => ({ vi.mock("../components/SecretsView", () => ({
SecretsView: () => <div data-testid="secrets-view" />, 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); expect.extend(jestDomMatchers);
afterEach(() => cleanup()); 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", () => { describe("NotificationsSection", () => {
it("emits the chosen failure-notification mode via setForm", () => { it("emits the chosen failure-notification mode via setForm", () => {
const setForm = vi.fn(); 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 */ /** Build the full relative path for a file/directory entry */
function entryPath(currentPath: string, name: string): string { function entryPath(currentPath: string, name: string): string {
if (currentPath.startsWith("/")) {
const base = currentPath === "/" ? "" : currentPath.replace(/\/+$/g, "");
return `${base}/${name}`;
}
return joinDisplayPath(currentPath, 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 ────────────────────────────────────────────────── // ── Context Menu State ──────────────────────────────────────────────────
interface ContextMenuState { interface ContextMenuState {
@@ -537,13 +557,13 @@ export function FileBrowser({
await deleteFile(workspace, dialog.entryFullPath, projectId); await deleteFile(workspace, dialog.entryFullPath, projectId);
break; break;
case "create-file": { case "create-file": {
const newFilePath = joinDisplayPath(dialog.entryFullPath, value); const newFilePath = entryPath(dialog.entryFullPath, value);
await createWorkspaceFile(workspace, newFilePath, projectId); await createWorkspaceFile(workspace, newFilePath, projectId);
onSelectFile(newFilePath); onSelectFile(newFilePath);
break; break;
} }
case "create-folder": case "create-folder":
await createWorkspaceDirectory(workspace, joinDisplayPath(dialog.entryFullPath, value), projectId); await createWorkspaceDirectory(workspace, entryPath(dialog.entryFullPath, value), projectId);
break; break;
} }
@@ -605,7 +625,7 @@ export function FileBrowser({
<button <button
className="file-browser-up" className="file-browser-up"
onClick={() => { onClick={() => {
onNavigate(getParentDisplayPath(currentPath)); onNavigate(parentPath(currentPath));
}} }}
> >
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} /> <ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />

View File

@@ -44,6 +44,13 @@ function isBinaryFile(filename: string): boolean {
function getParentDirectory(path: string): string { function getParentDirectory(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, ""); const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
const lastSlash = normalized.lastIndexOf("/"); 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) : "."; 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_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked"; const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
function isSlashPrefixedAbsolutePath(path: string): boolean {
return path.startsWith("/");
}
function DiscordIcon({ size = 13 }: { size?: number }) { function DiscordIcon({ size = 13 }: { size?: number }) {
return ( return (
<svg <svg
@@ -724,6 +728,7 @@ export function SettingsModal({
groupOverlappingFiles: true, groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true, ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [], overlapIgnorePaths: [],
allowAbsoluteFileBrowserPaths: false,
autoMerge: true, autoMerge: true,
planApprovalMode: "workflow", planApprovalMode: "workflow",
mergeStrategy: "direct", mergeStrategy: "direct",
@@ -807,7 +812,7 @@ export function SettingsModal({
loading: overlapPathPickerLoading, loading: overlapPathPickerLoading,
error: overlapPathPickerError, error: overlapPathPickerError,
refresh: refreshOverlapPathPicker, refresh: refreshOverlapPathPicker,
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId); } = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId, { allowAbsolutePaths: false });
const { const {
entries: worktreesDirPickerEntries, entries: worktreesDirPickerEntries,
@@ -816,7 +821,7 @@ export function SettingsModal({
loading: worktreesDirPickerLoading, loading: worktreesDirPickerLoading,
error: worktreesDirPickerError, error: worktreesDirPickerError,
refresh: refreshWorktreesDirPicker, refresh: refreshWorktreesDirPicker,
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId); } = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: false });
const { const {
entries: worktreeCopyFilePickerEntries, entries: worktreeCopyFilePickerEntries,
@@ -825,7 +830,7 @@ export function SettingsModal({
loading: worktreeCopyFilePickerLoading, loading: worktreeCopyFilePickerLoading,
error: worktreeCopyFilePickerError, error: worktreeCopyFilePickerError,
refresh: refreshWorktreeCopyFilePicker, refresh: refreshWorktreeCopyFilePicker,
} = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId); } = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId, { allowAbsolutePaths: false });
const { nodes } = useNodes(); const { nodes } = useNodes();
const experimentalFeatures = form.experimentalFeatures ?? {}; const experimentalFeatures = form.experimentalFeatures ?? {};
@@ -995,6 +1000,7 @@ export function SettingsModal({
const normalizedSettings = { const normalizedSettings = {
...s, ...s,
ignoreHiddenOverlapPaths: s.ignoreHiddenOverlapPaths ?? true, ignoreHiddenOverlapPaths: s.ignoreHiddenOverlapPaths ?? true,
allowAbsoluteFileBrowserPaths: s.allowAbsoluteFileBrowserPaths === true,
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree), mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync), mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s), maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
@@ -2034,7 +2040,11 @@ export function SettingsModal({
setImportFile(null); setImportFile(null);
// Refresh settings to show imported values // Refresh settings to show imported values
const refreshed = await fetchSettings(projectId); const refreshed = await fetchSettings(projectId);
setForm({ ...refreshed, ignoreHiddenOverlapPaths: refreshed.ignoreHiddenOverlapPaths ?? true }); setForm({
...refreshed,
ignoreHiddenOverlapPaths: refreshed.ignoreHiddenOverlapPaths ?? true,
allowAbsoluteFileBrowserPaths: refreshed.allowAbsoluteFileBrowserPaths === true,
});
} else { } else {
addToast(result.error || t("settings.importExport.importFailed", "Import failed"), "error"); addToast(result.error || t("settings.importExport.importFailed", "Import failed"), "error");
} }
@@ -2246,6 +2256,12 @@ export function SettingsModal({
const selectOverlapIgnorePath = useCallback((path: string) => { const selectOverlapIgnorePath = useCallback((path: string) => {
if (overlapPathPickerIndex === null) 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) => { setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0 const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
? [...f.overlapIgnorePaths] ? [...f.overlapIgnorePaths]
@@ -2285,12 +2301,16 @@ export function SettingsModal({
}, []); }, []);
const selectWorktreesDirFromPicker = useCallback((path: string) => { const selectWorktreesDirFromPicker = useCallback((path: string) => {
if (isSlashPrefixedAbsolutePath(path)) return;
const normalizedPath = path.endsWith("/") ? path : `${path}/`; const normalizedPath = path.endsWith("/") ? path : `${path}/`;
setForm((f) => ({ ...f, worktreesDir: normalizedPath })); setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
closeWorktreesDirPicker(); closeWorktreesDirPicker();
}, [closeWorktreesDirPicker]); }, [closeWorktreesDirPicker]);
const selectCurrentWorktreesDir = useCallback(() => { const selectCurrentWorktreesDir = useCallback(() => {
if (isSlashPrefixedAbsolutePath(worktreesDirPickerCurrentPath)) return;
const normalizedPath = worktreesDirPickerCurrentPath === "." const normalizedPath = worktreesDirPickerCurrentPath === "."
? "./" ? "./"
: (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`); : (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`);
@@ -2316,6 +2336,12 @@ export function SettingsModal({
const selectWorktreeCopyFile = useCallback((path: string) => { const selectWorktreeCopyFile = useCallback((path: string) => {
if (worktreeCopyFilePickerIndex === null) return; 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) => { setForm((f) => {
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0 const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
? [...f.worktreeCopyFiles] ? [...f.worktreeCopyFiles]

View File

@@ -213,6 +213,24 @@ describe("FileBrowserModal", () => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", true, undefined); 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 () => { it("switches workspace and notifies parent", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(

View File

@@ -234,6 +234,23 @@ describe("SettingsModal", () => {
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument(); 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: // FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" (SettingsView) was a zero-coverage branch. Assert the embedded contract via // 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 // 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> <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> <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> </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"> <div className="form-group">
<label htmlFor="completionDocumentationMode">{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}</label> <label htmlFor="completionDocumentationMode">{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}</label>
<select id="completionDocumentationMode" value={form.completionDocumentationMode || "off"} onChange={(e) => setForm((f) => ({ <select id="completionDocumentationMode" value={form.completionDocumentationMode || "off"} onChange={(e) => setForm((f) => ({

View File

@@ -72,6 +72,24 @@ describe("useWorkspaceFileBrowser", () => {
expect(result.current.entries).toEqual([]); 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 () => { it("returns hidden files and directories from the API response", async () => {
const mockResponse: FileListResponse = { const mockResponse: FileListResponse = {
path: ".", path: ".",

View File

@@ -12,6 +12,14 @@ interface UseWorkspaceFileBrowserReturn {
refresh: () => void; refresh: () => void;
} }
interface UseWorkspaceFileBrowserOptions {
allowAbsolutePaths?: boolean;
}
function isSlashPrefixedAbsolutePath(path: string): boolean {
return path.startsWith("/");
}
/** /**
* Hook for browsing files in a selected workspace. * Hook for browsing files in a selected workspace.
* *
@@ -23,25 +31,35 @@ export function useWorkspaceFileBrowser(
workspace: string, workspace: string,
enabled: boolean, enabled: boolean,
projectId?: string, projectId?: string,
options: UseWorkspaceFileBrowserOptions = {},
): UseWorkspaceFileBrowserReturn { ): UseWorkspaceFileBrowserReturn {
const [entries, setEntries] = useState<FileNode[]>([]); const [entries, setEntries] = useState<FileNode[]>([]);
const [currentPath, setCurrentPath] = useState<string>("."); const [currentPath, setCurrentPath] = useState<string>(".");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const allowAbsolutePaths = options.allowAbsolutePaths !== false;
const refresh = useCallback(() => { const refresh = useCallback(() => {
setRefreshKey((key) => key + 1); setRefreshKey((key) => key + 1);
}, []); }, []);
const setPath = useCallback((path: string) => { const setPath = useCallback((path: string) => {
if (!allowAbsolutePaths && isSlashPrefixedAbsolutePath(path)) {
setError("This picker only accepts project-relative paths");
return;
}
setCurrentPath(path); setCurrentPath(path);
setError(null); setError(null);
}, []); }, [allowAbsolutePaths]);
/* /*
FNXC:FileBrowser 2026-06-29-19:35: 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. 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(() => { useEffect(() => {
setCurrentPath("."); setCurrentPath(".");

View File

@@ -13,6 +13,7 @@ import {
searchWorkspaceFiles, searchWorkspaceFiles,
listProjectMarkdownFiles, listProjectMarkdownFiles,
scanMarkdownFiles, scanMarkdownFiles,
createWorkspaceDirectory,
copyWorkspaceFile, copyWorkspaceFile,
moveWorkspaceFile, moveWorkspaceFile,
deleteWorkspaceFile, deleteWorkspaceFile,
@@ -104,16 +105,27 @@ describe("MAX_FILE_SIZE", () => {
describe("path traversal protection", () => { describe("path traversal protection", () => {
const mockGetTask = vi.fn(); const mockGetTask = vi.fn();
const mockGetRootDir = vi.fn(); const mockGetRootDir = vi.fn();
const mockGetSettings = vi.fn();
const mockStore = { const mockStore = {
getTask: mockGetTask, getTask: mockGetTask,
getRootDir: mockGetRootDir, getRootDir: mockGetRootDir,
getSettings: mockGetSettings,
} as unknown as TaskStore; } as unknown as TaskStore;
beforeEach(() => { beforeEach(() => {
mockGetTask.mockReset(); mockGetTask.mockReset();
mockGetRootDir.mockReset(); mockGetRootDir.mockReset();
mockGetSettings.mockReset();
mockGetSettings.mockResolvedValue({ allowAbsoluteFileBrowserPaths: false });
mockStat.mockReset(); mockStat.mockReset();
mockReaddir.mockReset(); mockReaddir.mockReset();
mockReadFile.mockReset();
mockWriteFile.mockReset();
mockCopyFile.mockReset();
mockRename.mockReset();
mockRm.mockReset();
mockMkdir.mockReset();
mockAccess.mockReset();
mockExistsSync.mockReset(); mockExistsSync.mockReset();
}); });
@@ -139,11 +151,16 @@ describe("path traversal protection", () => {
await expect(listProjectFiles(mockStore, "file\0.txt")).rejects.toThrow("Invalid characters"); 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"); 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")).resolves.toMatchObject({
await expect(listProjectFiles(mockStore, "%2e%2e%2fsecret.txt")).rejects.toThrow("Path traversal detected"); 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); 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", () => { describe("complex path traversal patterns", () => {
@@ -833,7 +1021,7 @@ describe("URL-encoded characters handling", () => {
mockStat.mockReset(); 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"); mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({ mockStat.mockResolvedValue({
isFile: () => true, isFile: () => true,
@@ -844,9 +1032,8 @@ describe("URL-encoded characters handling", () => {
await readProjectFile(mockStore, "file%20name.txt"); await readProjectFile(mockStore, "file%20name.txt");
// Should decode %20 to space and look for the file
expect(mockReadFile).toHaveBeenCalledWith( expect(mockReadFile).toHaveBeenCalledWith(
"/test/project/file name.txt", "/test/project/file%20name.txt",
"utf-8", "utf-8",
); );
}); });

View File

@@ -1,7 +1,7 @@
import { join, resolve, relative, dirname, basename } from "node:path"; 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 { 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 { 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. * 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. * Validate and resolve a file path to ensure it stays within the allowed directory.
* Prevents directory traversal attacks. * 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 // Reject paths with null bytes
if (filePath.includes("\0")) { if (filePath.includes("\0")) {
throw new FileServiceError(`Access denied: Invalid characters in path`, "EINVAL"); throw new FileServiceError(`Access denied: Invalid characters in path`, "EINVAL");
} }
// Decode URL-encoded characters for security check if (filePath.match(/^[a-zA-Z]:/)) {
const decodedPath = decodeURIComponent(filePath); throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
}
if (filePath.startsWith("/") && options.allowAbsolutePaths === true) {
return resolve(filePath);
}
// Reject absolute paths // Reject absolute paths
if (decodedPath.startsWith("/") || decodedPath.match(/^[a-zA-Z]:/)) { if (filePath.startsWith("/")) {
throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL"); throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
} }
// Resolve the path against base path // Resolve the path against base path
const resolvedBase = resolve(basePath); 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 // Ensure the resolved path is within the base path
const relativePath = relative(resolvedBase, resolvedPath); const relativePath = relative(resolvedBase, resolvedPath);
@@ -156,8 +172,23 @@ function validatePath(basePath: string, filePath: string): string {
return resolvedPath; return resolvedPath;
} }
async function listFilesForBasePath(basePath: string, subPath?: string): Promise<FileListResponse> { function isOutsideBase(basePath: string, resolvedPath: string): boolean {
const targetPath = subPath ? validatePath(basePath, subPath) : basePath; 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; let stats;
try { try {
@@ -201,7 +232,7 @@ async function listFilesForBasePath(basePath: string, subPath?: string): Promise
const relativeBase = relative(basePath, targetPath); const relativeBase = relative(basePath, targetPath);
return { return {
path: relativeBase || ".", path: options.allowAbsolutePaths === true && isOutsideBase(basePath, targetPath) ? targetPath : (relativeBase || "."),
entries: fileNodes, entries: fileNodes,
}; };
} catch (err: unknown) { } 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) { if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL"); throw new FileServiceError("File path is required", "EINVAL");
} }
const resolvedPath = validatePath(basePath, filePath); const resolvedPath = validatePath(basePath, filePath, options);
let stats; let stats;
try { 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) { if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL"); 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"); 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 { try {
const stats = await stat(resolvedPath); const stats = await stat(resolvedPath);
@@ -438,7 +469,8 @@ export async function listWorkspaceFiles(
subPath?: string, subPath?: string,
): Promise<FileListResponse> { ): Promise<FileListResponse> {
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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, filePath: string,
): Promise<FileContentResponse> { ): Promise<FileContentResponse> {
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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, content: string,
): Promise<SaveFileResponse> { ): Promise<SaveFileResponse> {
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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) ───────── // ── Workspace File Operations (Create, Copy, Move, Delete, Rename) ─────────
@@ -487,7 +521,8 @@ export async function createWorkspaceDirectory(
} }
const workspaceBase = await getWorkspaceBasePath(store, workspace); const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, dirPath); const pathOptions = await getWorkspacePathValidationOptions(store);
const resolvedPath = validatePath(workspaceBase, dirPath, pathOptions);
try { try {
await stat(resolvedPath); await stat(resolvedPath);
@@ -536,13 +571,13 @@ export async function createWorkspaceDirectory(
* Validate that both source and destination paths are within the allowed workspace. * Validate that both source and destination paths are within the allowed workspace.
* Prevents copying/moving files outside the workspace boundary. * Prevents copying/moving files outside the workspace boundary.
*/ */
function validateSourceAndDestination(basePath: string, sourcePath: string, destinationPath: string): { resolvedSource: string; resolvedDest: string } { function validateSourceAndDestination(basePath: string, sourcePath: string, destinationPath: string, options: PathValidationOptions = {}): { resolvedSource: string; resolvedDest: string } {
const resolvedSource = validatePath(basePath, sourcePath); const resolvedSource = validatePath(basePath, sourcePath, options);
const resolvedDest = validatePath(basePath, destinationPath); 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); 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"); 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 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 // Verify source exists
let sourceStats; let sourceStats;
@@ -654,7 +690,8 @@ export async function moveWorkspaceFile(
} }
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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 // Verify source exists
try { try {
@@ -731,11 +768,12 @@ export async function deleteWorkspaceFile(
} }
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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); 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"); throw new FileServiceError("Cannot delete workspace root directory", "EINVAL");
} }
@@ -797,11 +835,12 @@ export async function renameWorkspaceFile(
} }
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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); 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"); throw new FileServiceError("Cannot rename workspace root directory", "EINVAL");
} }
@@ -819,14 +858,16 @@ export async function renameWorkspaceFile(
// Build destination path by replacing the basename // Build destination path by replacing the basename
const destPath = join(dirname(resolvedPath), newName); const destPath = join(dirname(resolvedPath), newName);
// Validate destination stays within workspace // Validate destination stays within workspace unless the source path was explicitly allowed as an absolute file-browser path.
const destRelative = relative(resolve(workspaceBase), destPath); if (pathOptions.allowAbsolutePaths !== true || !filePath.startsWith("/")) {
if (destRelative.startsWith("..") || destRelative.startsWith("../") || destRelative === "..") { const destRelative = relative(resolve(workspaceBase), destPath);
throw new FileServiceError("Destination would be outside workspace", "EINVAL"); if (destRelative.startsWith("..") || destRelative.startsWith("../") || destRelative === "..") {
} throw new FileServiceError("Destination would be outside workspace", "EINVAL");
}
if (!destPath.startsWith(resolve(workspaceBase))) { if (!destPath.startsWith(resolve(workspaceBase))) {
throw new FileServiceError("Destination would be outside workspace", "EINVAL"); throw new FileServiceError("Destination would be outside workspace", "EINVAL");
}
} }
// Check destination doesn't already exist // Check destination doesn't already exist
@@ -872,11 +913,12 @@ export async function getWorkspaceFileForDownload(
} }
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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); const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") { if (!relativePath || relativePath === "." || relativePath === "" || isFilesystemRoot(resolvedPath)) {
throw new FileServiceError("Cannot download workspace root", "EINVAL"); throw new FileServiceError("Cannot download workspace root", "EINVAL");
} }
@@ -925,11 +967,12 @@ export async function getWorkspaceFolderForZip(
} }
const workspaceBase = await getWorkspaceBasePath(store, workspace); 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); 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"); throw new FileServiceError("Cannot download workspace root as ZIP", "EINVAL");
} }