evOo-local/packages/adapters/src/project-workspace-files.ts
cynarAI 3c3961f242
fix(api): keep host folders to the deployment owner (#112)
spaces.create linked any workspacePath to the new project, and
computer.files and computer.readFile then listed and read files under
that folder for anyone in the space. On a deployment with signups, any
member could link / or the API's own checkout and read whatever the API
process can read, such as its .env file. Linking also ran git in the
chosen folder and stored its remote URL.

A linked folder lives on the host, and the host is the deployment
owner's machine, the rule the onboarding scan already follows:

- spaces.create refuses a folder from anyone but the deployment owner,
  before the space is created or the folder is touched.
- Project files are listed and read only for the deployment owner, so a
  space a member linked before this change stays closed too. Space
  navigation no longer shows a linked folder's path and git remote to
  other members.
- A linked folder is stored as its real path, and listing and reading
  follow symlinks only while they stay inside it. Symlinks that leave
  the folder or lead nowhere are left out of a listing instead of
  failing it.
- The web New project dialog offers the folder field only to the
  deployment owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00

73 lines
2.6 KiB
TypeScript

import { readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import type { ComputerFileEntry } from "@rakazo/adapter-kit";
import { normalizeWorkspacePath } from "./computer-support.js";
import { isAllowedDesktopPath } from "./desktop-sandbox-paths.js";
import {
isProjectWorkspacePath,
projectWorkspaceRelativePath,
resolveProjectHostPath,
} from "./project-workspace-path.js";
export async function listProjectWorkspaceFiles(
workspaceRoot: string,
storedPath: string,
): Promise<ComputerFileEntry[]> {
if (!isProjectWorkspacePath(storedPath)) {
throw new Error("Path is not under the project workspace");
}
const root = await realpath(workspaceRoot);
const hostDirectory = await containedHostPath(
root,
resolveProjectHostPath(workspaceRoot, storedPath),
);
const relativeDirectory = projectWorkspaceRelativePath(storedPath);
const entries = await readdir(hostDirectory, { withFileTypes: true });
const listed = await Promise.all(
entries.map(async (entry) => {
// A symlink that leaves the linked folder, or leads nowhere, is not part of the project.
const info = await containedHostPath(root, path.join(hostDirectory, entry.name))
.then((target) => stat(target))
.catch(() => null);
if (!info) return [];
const childStored = normalizeWorkspacePath(
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
);
return [
{
path: childStored,
kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
size: info.size,
...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}),
},
];
}),
);
return listed.flat();
}
export async function readProjectWorkspaceFile(
workspaceRoot: string,
storedPath: string,
options?: { maxBytes?: number },
): Promise<Uint8Array> {
const hostPath = await containedHostPath(
await realpath(workspaceRoot),
resolveProjectHostPath(workspaceRoot, storedPath),
);
const info = await stat(hostPath);
if (options?.maxBytes !== undefined && info.size > options.maxBytes) {
throw new Error(`computer file exceeds ${options.maxBytes} bytes`);
}
return new Uint8Array(await readFile(hostPath));
}
/** Follows symlinks, but only to places inside the linked folder. */
async function containedHostPath(root: string, candidate: string): Promise<string> {
const target = await realpath(candidate);
if (!isAllowedDesktopPath(target, [root])) {
throw new Error("Path escapes the project workspace");
}
return target;
}