fix(gateway): block agents.files symlink escapes

This commit is contained in:
Peter Steinberger
2026-02-26 00:30:43 +01:00
parent 45d59971e6
commit 125f4071bc
3 changed files with 421 additions and 21 deletions

View File

@@ -1,3 +1,4 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
@@ -27,6 +28,9 @@ import {
} from "../../commands/agents.config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import { sameFileIdentity } from "../../infra/file-identity.js";
import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js";
import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import {
@@ -97,10 +101,113 @@ type FileMeta = {
updatedAtMs: number;
};
async function statFile(filePath: string): Promise<FileMeta | null> {
type ResolvedAgentWorkspaceFilePath =
| {
kind: "ready";
requestPath: string;
ioPath: string;
workspaceReal: string;
}
| {
kind: "missing";
requestPath: string;
ioPath: string;
workspaceReal: string;
}
| {
kind: "invalid";
requestPath: string;
reason: string;
};
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const OPEN_WRITE_FLAGS =
fsConstants.O_WRONLY |
fsConstants.O_CREAT |
fsConstants.O_TRUNC |
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
async function resolveWorkspaceRealPath(workspaceDir: string): Promise<string> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return await fs.realpath(workspaceDir);
} catch {
return path.resolve(workspaceDir);
}
}
async function resolveAgentWorkspaceFilePath(params: {
workspaceDir: string;
name: string;
allowMissing: boolean;
}): Promise<ResolvedAgentWorkspaceFilePath> {
const requestPath = path.join(params.workspaceDir, params.name);
const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir);
const candidatePath = path.resolve(workspaceReal, params.name);
if (!isPathInside(workspaceReal, candidatePath)) {
return { kind: "invalid", requestPath, reason: "path escapes workspace root" };
}
let candidateLstat: Awaited<ReturnType<typeof fs.lstat>>;
try {
candidateLstat = await fs.lstat(candidatePath);
} catch (err) {
if (isNotFoundPathError(err)) {
if (params.allowMissing) {
return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
}
return { kind: "invalid", requestPath, reason: "file not found" };
}
throw err;
}
if (candidateLstat.isSymbolicLink()) {
let targetReal: string;
try {
targetReal = await fs.realpath(candidatePath);
} catch (err) {
if (isNotFoundPathError(err)) {
if (params.allowMissing) {
return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
}
return { kind: "invalid", requestPath, reason: "symlink target not found" };
}
throw err;
}
if (!isPathInside(workspaceReal, targetReal)) {
return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" };
}
try {
const targetStat = await fs.stat(targetReal);
if (!targetStat.isFile()) {
return { kind: "invalid", requestPath, reason: "symlink target is not a file" };
}
} catch (err) {
if (isNotFoundPathError(err) && params.allowMissing) {
return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal };
}
throw err;
}
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
}
if (!candidateLstat.isFile()) {
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
}
const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath);
if (!isPathInside(workspaceReal, candidateReal)) {
return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" };
}
return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal };
}
async function statFileSafely(filePath: string): Promise<FileMeta | null> {
try {
const [stat, lstat] = await Promise.all([fs.stat(filePath), fs.lstat(filePath)]);
if (lstat.isSymbolicLink() || !stat.isFile()) {
return null;
}
if (!sameFileIdentity(stat, lstat)) {
return null;
}
return {
@@ -112,6 +219,22 @@ async function statFile(filePath: string): Promise<FileMeta | null> {
}
}
async function writeFileSafely(filePath: string, content: string): Promise<void> {
const handle = await fs.open(filePath, OPEN_WRITE_FLAGS, 0o600);
try {
const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]);
if (lstat.isSymbolicLink() || !stat.isFile()) {
throw new Error("unsafe file path");
}
if (!sameFileIdentity(stat, lstat)) {
throw new Error("path changed during write");
}
await handle.writeFile(content, "utf-8");
} finally {
await handle.close().catch(() => {});
}
}
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
const files: Array<{
name: string;
@@ -125,8 +248,18 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
: BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) {
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
const resolved = await resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
const filePath = resolved.requestPath;
const meta =
resolved.kind === "ready"
? await statFileSafely(resolved.ioPath)
: resolved.kind === "missing"
? null
: null;
if (meta) {
files.push({
name,
@@ -140,29 +273,43 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
}
}
const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME);
const primaryMeta = await statFile(primaryMemoryPath);
const primaryResolved = await resolveAgentWorkspaceFilePath({
workspaceDir,
name: DEFAULT_MEMORY_FILENAME,
allowMissing: true,
});
const primaryMeta =
primaryResolved.kind === "ready" ? await statFileSafely(primaryResolved.ioPath) : null;
if (primaryMeta) {
files.push({
name: DEFAULT_MEMORY_FILENAME,
path: primaryMemoryPath,
path: primaryResolved.requestPath,
missing: false,
size: primaryMeta.size,
updatedAtMs: primaryMeta.updatedAtMs,
});
} else {
const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME);
const altMeta = await statFile(altMemoryPath);
const altMemoryResolved = await resolveAgentWorkspaceFilePath({
workspaceDir,
name: DEFAULT_MEMORY_ALT_FILENAME,
allowMissing: true,
});
const altMeta =
altMemoryResolved.kind === "ready" ? await statFileSafely(altMemoryResolved.ioPath) : null;
if (altMeta) {
files.push({
name: DEFAULT_MEMORY_ALT_FILENAME,
path: altMemoryPath,
path: altMemoryResolved.requestPath,
missing: false,
size: altMeta.size,
updatedAtMs: altMeta.updatedAtMs,
});
} else {
files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true });
files.push({
name: DEFAULT_MEMORY_FILENAME,
path: primaryResolved.requestPath,
missing: true,
});
}
}
@@ -453,8 +600,23 @@ export const agentsHandlers: GatewayRequestHandlers = {
}
const { agentId, workspaceDir, name } = resolved;
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
if (!meta) {
const resolvedPath = await resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsafe workspace file "${name}" (${resolvedPath.reason})`,
),
);
return;
}
if (resolvedPath.kind === "missing") {
respond(
true,
{
@@ -466,7 +628,29 @@ export const agentsHandlers: GatewayRequestHandlers = {
);
return;
}
const content = await fs.readFile(filePath, "utf-8");
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
try {
safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath });
} catch (err) {
if (err instanceof SafeOpenError && err.code === "not-found") {
respond(
true,
{
agentId,
workspace: workspaceDir,
file: { name, path: filePath, missing: true },
},
undefined,
);
return;
}
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
);
return;
}
respond(
true,
{
@@ -476,9 +660,9 @@ export const agentsHandlers: GatewayRequestHandlers = {
name,
path: filePath,
missing: false,
size: meta.size,
updatedAtMs: meta.updatedAtMs,
content,
size: safeRead.stat.size,
updatedAtMs: Math.floor(safeRead.stat.mtimeMs),
content: safeRead.buffer.toString("utf-8"),
},
},
undefined,
@@ -505,9 +689,34 @@ export const agentsHandlers: GatewayRequestHandlers = {
const { agentId, workspaceDir, name } = resolved;
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsafe workspace file "${name}" (${resolvedPath.reason})`,
),
);
return;
}
const content = String(params.content ?? "");
await fs.writeFile(filePath, content, "utf-8");
const meta = await statFile(filePath);
try {
await writeFileSafely(resolvedPath.ioPath, content);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
);
return;
}
const meta = await statFileSafely(resolvedPath.ioPath);
respond(
true,
{