Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"schema:config": "tsx scripts/generate-config-schema.ts",
"start": "node dist/cli.js serve",
"test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/tool-result.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-show-changes.test.ts && tsx src/cli.test.ts",
"test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/tool-result.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/pi-tools.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-show-changes.test.ts && tsx src/cli.test.ts",
"typecheck": "tsx src/config-schema.test.ts && tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
4 changes: 3 additions & 1 deletion src/git-worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { promisify } from "node:util";
import { mkdir, realpath, rm, stat } from "node:fs/promises";
import { basename, join, relative, resolve } from "node:path";
import type { ServerConfig } from "./config.js";
import { assertAllowedPath, isPathInsideRoot } from "./roots.js";
import { assertAllowedPath, assertCanonicalAllowedPath, isPathInsideRoot } from "./roots.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -39,6 +39,7 @@ export async function createManagedWorktree(input: {
config: ServerConfig;
}): Promise<ManagedWorktree> {
const sourcePath = assertAllowedPath(input.sourcePath, input.config.allowedRoots);
await assertCanonicalAllowedPath(sourcePath, input.config.allowedRoots);

try {
const sourceStats = await stat(sourcePath);
Expand Down Expand Up @@ -67,6 +68,7 @@ export async function createManagedWorktree(input: {

await mkdir(input.config.worktreeRoot, { recursive: true });
assertAllowedPath(worktreePath, [input.config.worktreeRoot]);
await assertCanonicalAllowedPath(worktreePath, [input.config.worktreeRoot]);

try {
await git(["worktree", "add", "--detach", worktreePath, baseSha], sourceRoot);
Expand Down
64 changes: 64 additions & 0 deletions src/pi-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { editFileTool, readFileTool, writeFileTool } from "./pi-tools.js";

const fixtureRoot = await mkdtemp(join(tmpdir(), "devspace-pi-tools-test-"));
try {
const workspace = join(fixtureRoot, "workspace");
const outside = join(fixtureRoot, "outside");
const inside = join(workspace, "inside");
await mkdir(inside, { recursive: true });
await mkdir(outside, { recursive: true });
await writeFile(join(outside, "secret.txt"), "outside secret\n");
await writeFile(join(outside, "editable.txt"), "before\n");

const outsideLink = join(workspace, "outside-link");
await symlink(outside, outsideLink, process.platform === "win32" ? "junction" : "dir");
const context = { cwd: workspace, root: workspace };

await assert.rejects(
readFileTool({ path: "outside-link/secret.txt" }, context),
/outside allowed roots/,
);
await assert.rejects(
writeFileTool({ path: "outside-link/new.txt", content: "escaped\n" }, context),
/outside allowed roots/,
);
await assert.rejects(readFile(join(outside, "new.txt"), "utf8"), /ENOENT/);

await assert.rejects(
editFileTool(
{
path: "outside-link/editable.txt",
edits: [{ oldText: "before", newText: "after" }],
},
context,
),
/outside allowed roots/,
);
assert.equal(await readFile(join(outside, "editable.txt"), "utf8"), "before\n");

const insideLink = join(workspace, "inside-link");
await symlink(inside, insideLink, process.platform === "win32" ? "junction" : "dir");
const safeWrite = await writeFileTool(
{ path: "inside-link/new.txt", content: "inside\n" },
context,
);
assert.equal(safeWrite.isError, undefined);
assert.equal(await readFile(join(inside, "new.txt"), "utf8"), "inside\n");

if (process.platform !== "win32") {
const danglingLink = join(workspace, "dangling-link");
const danglingTarget = join(outside, "dangling-created.txt");
await symlink(danglingTarget, danglingLink);
await assert.rejects(
writeFileTool({ path: "dangling-link", content: "escaped\n" }, context),
/Cannot resolve symbolic link/,
);
await assert.rejects(readFile(danglingTarget, "utf8"), /ENOENT/);
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
12 changes: 8 additions & 4 deletions src/pi-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type WriteToolInput,
type AgentToolResult,
} from "@earendil-works/pi-coding-agent";
import { resolveAllowedPath } from "./roots.js";
import { resolveCanonicalAllowedPath } from "./roots.js";

type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
export type ToolResponse<TDetails = unknown> = {
Expand Down Expand Up @@ -61,7 +61,11 @@ async function runTool<TInput, TDetails = unknown>(
}

export async function readFileTool(input: ReadToolInput, context: ToolContext): Promise<ToolResponse> {
const path = resolveAllowedPath(input.path, context.cwd, context.readRoots ?? [context.root]);
const path = await resolveCanonicalAllowedPath(
input.path,
context.cwd,
context.readRoots ?? [context.root],
);
const tool = createReadTool(context.cwd);

return runTool((params) => tool.execute("read_file", params), {
Expand All @@ -72,7 +76,7 @@ export async function readFileTool(input: ReadToolInput, context: ToolContext):
}

export async function writeFileTool(input: WriteToolInput, context: ToolContext): Promise<ToolResponse> {
const path = resolveAllowedPath(input.path, context.cwd, [context.root]);
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, [context.root]);
const tool = createWriteTool(context.cwd);

return runTool((params) => tool.execute("write_file", params), {
Expand All @@ -82,7 +86,7 @@ export async function writeFileTool(input: WriteToolInput, context: ToolContext)
}

export async function editFileTool(input: EditToolInput, context: ToolContext): Promise<ToolResponse<EditToolDetails>> {
const path = resolveAllowedPath(input.path, context.cwd, [context.root]);
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, [context.root]);
const tool = createEditTool(context.cwd);

return runTool((params) => tool.execute("edit_file", params), {
Expand Down
55 changes: 53 additions & 2 deletions src/roots.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import assert from "node:assert/strict";
import { homedir } from "node:os";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { assertAllowedPath, expandHomePath, resolveAllowedPath } from "./roots.js";
import {
assertAllowedPath,
expandHomePath,
resolveAllowedPath,
resolveCanonicalAllowedPath,
} from "./roots.js";

const home = homedir();

Expand Down Expand Up @@ -31,3 +37,48 @@ if (process.platform === "win32") {
/Path is outside allowed roots/,
);
}

const fixtureRoot = await mkdtemp(join(tmpdir(), "devspace-roots-test-"));
try {
const workspace = join(fixtureRoot, "workspace");
const outside = join(fixtureRoot, "outside");
await mkdir(join(workspace, "inside"), { recursive: true });
await mkdir(outside, { recursive: true });
await writeFile(join(outside, "secret.txt"), "secret\n");

const outsideLink = join(workspace, "outside-link");
await symlink(outside, outsideLink, process.platform === "win32" ? "junction" : "dir");
await assert.rejects(
resolveCanonicalAllowedPath(join(outsideLink, "secret.txt"), workspace, [workspace]),
/outside allowed roots/,
);
await assert.rejects(
resolveCanonicalAllowedPath(join(outsideLink, "new.txt"), workspace, [workspace]),
/outside allowed roots/,
);

const insideLink = join(workspace, "inside-link");
await symlink(join(workspace, "inside"), insideLink, process.platform === "win32" ? "junction" : "dir");
assert.equal(
await resolveCanonicalAllowedPath(join(insideLink, "new.txt"), workspace, [workspace]),
join(workspace, "inside", "new.txt"),
);

const outsideAlias = join(outside, "workspace-link");
await symlink(workspace, outsideAlias, process.platform === "win32" ? "junction" : "dir");
await assert.rejects(
resolveCanonicalAllowedPath(join(outsideAlias, "inside"), workspace, [workspace]),
/outside allowed roots/,
);

if (process.platform !== "win32") {
const danglingLink = join(workspace, "dangling-link");
await symlink(join(outside, "missing.txt"), danglingLink);
await assert.rejects(
resolveCanonicalAllowedPath(danglingLink, workspace, [workspace]),
/Cannot resolve symbolic link/,
);
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
61 changes: 60 additions & 1 deletion src/roots.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { homedir } from "node:os";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { lstat, realpath } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";

export class AccessDeniedError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -44,3 +45,61 @@ export function resolveAllowedPath(inputPath: string, cwd: string, allowedRoots:
const absolutePath = resolve(cwd, inputPath);
return assertAllowedPath(absolutePath, allowedRoots);
}

export async function resolveCanonicalAllowedPath(
inputPath: string,
cwd: string,
allowedRoots: string[],
): Promise<string> {
const absolutePath = resolveAllowedPath(inputPath, cwd, allowedRoots);
const canonicalPath = await canonicalizePath(absolutePath);

for (const root of allowedRoots) {
const canonicalRoot = await canonicalizePath(root);
if (isPathInsideRoot(canonicalPath, canonicalRoot)) return canonicalPath;
Comment on lines +58 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Missing roots widen authorization

If an allowedRoots entry names a nonexistent directory, canonicalizePath(root) resolves it to the nearest existing ancestor—potentially /—and uses that ancestor as the authorization boundary, allowing operations outside the configured root. A dangling root also throws before later valid roots are evaluated, rejecting legitimate operations.

How this was verified: Tracing a missing configured root through canonicalizePath shows that it resolves to an existing ancestor which is then used directly as the authorization boundary.

}

throw new AccessDeniedError(`Path is outside allowed roots: ${inputPath}`);
}

export async function assertCanonicalAllowedPath(path: string, allowedRoots: string[]): Promise<string> {
return resolveCanonicalAllowedPath(path, process.cwd(), allowedRoots);
}

async function canonicalizePath(path: string): Promise<string> {
const absolutePath = resolve(expandHomePath(path));
let candidate = absolutePath;

for (;;) {
try {
const boundaryPath = await realpath(candidate);
const suffix = relative(candidate, absolutePath);
return suffix ? resolve(boundaryPath, suffix) : boundaryPath;
} catch (error) {
if (!isMissingPathError(error)) throw error;

try {
const stats = await lstat(candidate);
if (stats.isSymbolicLink()) {
throw new AccessDeniedError(`Cannot resolve symbolic link: ${path}`);
}
} catch (lstatError) {
if (!isMissingPathError(lstatError)) throw lstatError;
}

const parent = dirname(candidate);
if (parent === candidate) throw error;
candidate = parent;
}
}
}

function isMissingPathError(error: unknown): boolean {
return Boolean(
typeof error === "object" &&
error &&
"code" in error &&
((error as NodeJS.ErrnoException).code === "ENOENT" ||
(error as NodeJS.ErrnoException).code === "ENOTDIR"),
);
}
19 changes: 19 additions & 0 deletions src/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ test("workspace paths outside the allowed roots are rejected", async (t) => {
);
});

test("workspace paths cannot escape allowed roots through symlinks", async (t) => {
const context = await fixture(t);
const outsideLink = join(context.root, "outside-link");
await symlink(
context.outsideRoot,
outsideLink,
platform() === "win32" ? "junction" : "dir",
);

await assert.rejects(
() => context.registry.openWorkspace(outsideLink),
/outside allowed roots/,
);
await assert.rejects(
() => context.registry.openWorkspace({ path: outsideLink, mode: "worktree" }),
/outside allowed roots/,
);
});

test("a symlinked allowed root preserves checkout and worktree path behavior", { skip: platform() === "win32" }, async (t) => {
const context = await fixture(t);
const aliasRoot = join(context.root, "alias-root");
Expand Down
4 changes: 4 additions & 0 deletions src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createManagedWorktree } from "./git-worktrees.js";
import {
AccessDeniedError,
assertAllowedPath,
assertCanonicalAllowedPath,
isPathInsideRoot,
resolveAllowedPath,
} from "./roots.js";
Expand Down Expand Up @@ -201,6 +202,7 @@ export class WorkspaceRegistry {
let root: string;
try {
root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot);
await assertCanonicalAllowedPath(root, this.config.allowedRoots);
const rootStats = await stat(root);
if (!rootStats.isDirectory()) return undefined;
} catch (error) {
Expand All @@ -221,6 +223,7 @@ export class WorkspaceRegistry {

private async conversationProjectKey(input: OpenWorkspaceInput): Promise<string> {
const path = assertAllowedPath(input.path, this.config.allowedRoots);
await assertCanonicalAllowedPath(path, this.config.allowedRoots);
return canonicalPath(path);
}

Expand Down Expand Up @@ -327,6 +330,7 @@ export class WorkspaceRegistry {

private async openCheckoutWorkspace(path: string): Promise<WorkspaceContext> {
const root = assertAllowedPath(path, this.config.allowedRoots);
await assertCanonicalAllowedPath(root, this.config.allowedRoots);
const rootStats = await ensureCheckoutWorkspaceRoot(root);
if (!rootStats.isDirectory()) {
throw new Error(`Workspace root must be a directory: ${path}`);
Expand Down
Loading