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:
7
.changeset/fn-7264-absolute-file-browser-paths.md
Normal file
7
.changeset/fn-7264-absolute-file-browser-paths.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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` —
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)" }} />
|
||||
|
||||
@@ -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) : ".";
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: ".",
|
||||
|
||||
@@ -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(".");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user