From 71d7f075bf0602e6845a3013648bc3b960003868 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 8 Jul 2026 01:04:41 +0530 Subject: [PATCH 1/2] Resolve symlinked AGENTS context files --- src/workspaces.test.ts | 8 +++++- src/workspaces.ts | 56 ++++++++++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 87f6b35a..bcec48d6 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -15,7 +15,13 @@ const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); try { const agentDir = join(root, ".pi", "agent"); await mkdir(agentDir, { recursive: true }); - await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + await mkdir(join(agentDir, "skills"), { recursive: true }); + await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); + if (platform() === "win32") { + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + } else { + await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); + } await writeFile(join(root, "AGENTS.md"), "root instructions\n"); await mkdir(join(root, ".devspace", "agents"), { recursive: true }); await writeFile( diff --git a/src/workspaces.ts b/src/workspaces.ts index 673d0823..396a17f9 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js"; -import { mkdir, opendir, stat } from "node:fs/promises"; +import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; @@ -220,7 +220,7 @@ export class WorkspaceRegistry { managed: workspace.worktree?.managed, }); this.workspaces.set(workspace.id, workspace); - const agentsFiles = this.loadInitialAgentsFiles(workspace.root); + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); return { workspace, agentsFiles, availableAgentsFiles }; @@ -246,19 +246,21 @@ export class WorkspaceRegistry { return assertAllowedPath(root, this.config.allowedRoots); } - private loadInitialAgentsFiles(root: string): LoadedAgentsFile[] { + private async loadInitialAgentsFiles(root: string): Promise { const agentDir = resolve(this.config.agentDir); + const loadedFiles: LoadedAgentsFile[] = []; - return loadProjectContextFiles({ cwd: root, agentDir }) - .filter((file) => { - const path = resolve(file.path); - if (isPathInsideRoot(path, agentDir)) return true; - return isPathInsideRoot(path, root) && dirname(path) === root; - }) - .map((file) => ({ - path: resolve(file.path), - content: file.content, - })); + for (const file of loadProjectContextFiles({ cwd: root, agentDir })) { + const path = resolve(file.path); + if (!isInitialAgentsFilePath(path, root, agentDir)) continue; + + loadedFiles.push({ + path, + content: await readResolvedContextFile(path, file.content), + }); + } + + return loadedFiles; } private async findAvailableAgentsFiles( @@ -266,12 +268,19 @@ export class WorkspaceRegistry { loadedFiles: LoadedAgentsFile[], ): Promise { const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path))); + const loadedRealPaths = new Set(); + for (const file of loadedFiles) { + const realPath = await tryRealpath(file.path); + if (realPath) loadedRealPaths.add(realPath); + } const discovered: AvailableAgentsFile[] = []; await walkWorkspace(root, async (path, entry) => { if (!entry.isFile()) return; if (!CONTEXT_FILE_NAMES.has(entry.name)) return; if (loadedPaths.has(path)) return; + const realPath = await tryRealpath(path); + if (realPath && loadedRealPaths.has(realPath)) return; discovered.push({ path }); }); @@ -310,6 +319,27 @@ export function formatAgentsPath(path: string, workspaceRoot: string | undefined return relationship.split(sep).join("/"); } +function isInitialAgentsFilePath(path: string, root: string, agentDir: string): boolean { + if (isPathInsideRoot(path, agentDir)) return true; + return isPathInsideRoot(path, root) && dirname(path) === root; +} + +async function readResolvedContextFile(path: string, fallbackContent: string): Promise { + try { + return await readFile(await realpath(path), "utf8"); + } catch { + return fallbackContent; + } +} + +async function tryRealpath(path: string): Promise { + try { + return await realpath(path); + } catch { + return undefined; + } +} + async function walkWorkspace( directory: string, visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise | void, From 94afa090537d47a2d48a928635fdc581e3e54ebe Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 8 Jul 2026 01:38:27 +0530 Subject: [PATCH 2/2] Reject escaped AGENTS symlink targets --- src/workspaces.test.ts | 22 ++++++++++++++++++++++ src/workspaces.ts | 15 ++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index bcec48d6..7b8136fd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -11,6 +11,7 @@ import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); +const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); try { const agentDir = join(root, ".pi", "agent"); @@ -79,6 +80,26 @@ try { ], ); + if (platform() !== "win32") { + const unsafeAgentDir = join(root, ".pi", "unsafe-agent"); + await mkdir(unsafeAgentDir, { recursive: true }); + await writeFile(join(outsideRoot, "secret.txt"), "outside secret\n"); + await symlink(join(outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); + const unsafeConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-unsafe-home"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unsafe-worktrees"), + DEVSPACE_AGENT_DIR: unsafeAgentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(root); + assert.deepEqual( + unsafeWorkspace.agentsFiles.map((file) => file.content), + ["root instructions\n"], + ); + } + const missingWorkspaceRoot = join(root, "missing", "workspace"); const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); @@ -161,6 +182,7 @@ try { } } finally { await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/workspaces.ts b/src/workspaces.ts index 396a17f9..466b1b40 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -253,10 +253,12 @@ export class WorkspaceRegistry { for (const file of loadProjectContextFiles({ cwd: root, agentDir })) { const path = resolve(file.path); if (!isInitialAgentsFilePath(path, root, agentDir)) continue; + const content = await readResolvedContextFile(path, file.content, root, agentDir); + if (content === undefined) continue; loadedFiles.push({ path, - content: await readResolvedContextFile(path, file.content), + content, }); } @@ -324,9 +326,16 @@ function isInitialAgentsFilePath(path: string, root: string, agentDir: string): return isPathInsideRoot(path, root) && dirname(path) === root; } -async function readResolvedContextFile(path: string, fallbackContent: string): Promise { +async function readResolvedContextFile( + path: string, + fallbackContent: string, + root: string, + agentDir: string, +): Promise { try { - return await readFile(await realpath(path), "utf8"); + const resolvedPath = await realpath(path); + if (!isInitialAgentsFilePath(resolvedPath, root, agentDir)) return undefined; + return await readFile(resolvedPath, "utf8"); } catch { return fallbackContent; }