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

@@ -26,7 +26,10 @@ const mocks = vi.hoisted(() => ({
fsMkdir: vi.fn(async () => undefined),
fsAppendFile: vi.fn(async () => {}),
fsReadFile: vi.fn(async () => ""),
fsStat: vi.fn(async () => null),
fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsRealpath: vi.fn(async (p: string) => p),
fsOpen: vi.fn(async () => ({}) as unknown),
}));
vi.mock("../../config/config.js", () => ({
@@ -85,6 +88,9 @@ vi.mock("node:fs/promises", async () => {
appendFile: mocks.fsAppendFile,
readFile: mocks.fsReadFile,
stat: mocks.fsStat,
lstat: mocks.fsLstat,
realpath: mocks.fsRealpath,
open: mocks.fsOpen,
};
return { ...patched, default: patched };
});
@@ -125,6 +131,33 @@ function createErrnoError(code: string) {
return err;
}
function makeFileStat(params?: {
size?: number;
mtimeMs?: number;
dev?: number;
ino?: number;
}): import("node:fs").Stats {
return {
isFile: () => true,
isSymbolicLink: () => false,
size: params?.size ?? 10,
mtimeMs: params?.mtimeMs ?? 1234,
dev: params?.dev ?? 1,
ino: params?.ino ?? 1,
} as unknown as import("node:fs").Stats;
}
function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats {
return {
isFile: () => false,
isSymbolicLink: () => true,
size: 0,
mtimeMs: 0,
dev: params?.dev ?? 1,
ino: params?.ino ?? 2,
} as unknown as import("node:fs").Stats;
}
function mockWorkspaceStateRead(params: {
onboardingCompletedAt?: string;
errorCode?: string;
@@ -172,6 +205,19 @@ beforeEach(() => {
mocks.fsStat.mockImplementation(async () => {
throw createEnoentError();
});
mocks.fsLstat.mockImplementation(async () => {
throw createEnoentError();
});
mocks.fsRealpath.mockImplementation(async (p: string) => p);
mocks.fsOpen.mockImplementation(
async () =>
({
stat: async () => makeFileStat(),
readFile: async () => Buffer.from(""),
writeFile: async () => {},
close: async () => {},
}) as unknown,
);
});
/* ------------------------------------------------------------------ */
@@ -459,3 +505,147 @@ describe("agents.files.list", () => {
expect(names).toContain("BOOTSTRAP.md");
});
});
describe("agents.files.get/set symlink safety", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
mocks.fsMkdir.mockResolvedValue(undefined);
});
it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => {
const workspace = "/workspace/test-agent";
const candidate = `${workspace}/AGENTS.md`;
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return "/outside/secret.txt";
}
return p;
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat();
}
throw createEnoentError();
});
const { respond, promise } = makeCall("agents.files.get", {
agentId: "main",
name: "AGENTS.md",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
});
it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => {
const workspace = "/workspace/test-agent";
const candidate = `${workspace}/AGENTS.md`;
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return "/outside/secret.txt";
}
return p;
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat();
}
throw createEnoentError();
});
const { respond, promise } = makeCall("agents.files.set", {
agentId: "main",
name: "AGENTS.md",
content: "x",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
expect(mocks.fsOpen).not.toHaveBeenCalled();
});
it("allows in-workspace symlink targets for get/set", async () => {
const workspace = "/workspace/test-agent";
const candidate = `${workspace}/AGENTS.md`;
const target = `${workspace}/policies/AGENTS.md`;
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return target;
}
return p;
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat({ dev: 9, ino: 41 });
}
if (p === target) {
return targetStat;
}
throw createEnoentError();
});
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === target) {
return targetStat;
}
throw createEnoentError();
});
mocks.fsOpen.mockImplementation(
async () =>
({
stat: async () => targetStat,
readFile: async () => Buffer.from("inside\n"),
writeFile: async () => {},
close: async () => {},
}) as unknown,
);
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
await getCall.promise;
expect(getCall.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
file: expect.objectContaining({ missing: false, content: "inside\n" }),
}),
undefined,
);
const setCall = makeCall("agents.files.set", {
agentId: "main",
name: "AGENTS.md",
content: "updated\n",
});
await setCall.promise;
expect(setCall.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
ok: true,
file: expect.objectContaining({ missing: false, content: "updated\n" }),
}),
undefined,
);
});
});