fix(security): harden workspace bootstrap boundary reads

This commit is contained in:
Peter Steinberger
2026-03-02 17:07:26 +00:00
parent 67b2dde7c5
commit 07b16d5ad0
8 changed files with 190 additions and 7 deletions

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -13,6 +16,7 @@ const {
formatToolFailuresSection,
computeAdaptiveChunkRatio,
isOversizedForSummary,
readWorkspaceContextForSummary,
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,
@@ -484,3 +488,41 @@ describe("compaction-safeguard double-compaction guard", () => {
expect(getApiKeyMock).toHaveBeenCalled();
});
});
describe("readWorkspaceContextForSummary", () => {
it.runIf(process.platform !== "win32")(
"returns empty when AGENTS.md is a symlink escape",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.symlinkSync(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
},
);
it.runIf(process.platform !== "win32")(
"returns empty when AGENTS.md is a hardlink alias",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
const prevCwd = process.cwd();
try {
const outside = path.join(root, "outside-secret.txt");
fs.writeFileSync(outside, "secret");
fs.linkSync(outside, path.join(root, "AGENTS.md"));
process.chdir(root);
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
} finally {
process.chdir(prevCwd);
fs.rmSync(root, { recursive: true, force: true });
}
},
);
});

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
BASE_CHUNK_RATIO,
@@ -169,11 +170,22 @@ async function readWorkspaceContextForSummary(): Promise<string> {
const agentsPath = path.join(workspaceDir, "AGENTS.md");
try {
if (!fs.existsSync(agentsPath)) {
const opened = await openBoundaryFile({
absolutePath: agentsPath,
rootPath: workspaceDir,
boundaryLabel: "workspace root",
});
if (!opened.ok) {
return "";
}
const content = await fs.promises.readFile(agentsPath, "utf-8");
const content = (() => {
try {
return fs.readFileSync(opened.fd, "utf-8");
} finally {
fs.closeSync(opened.fd);
}
})();
const sections = extractSections(content, ["Session Startup", "Red Lines"]);
if (sections.length === 0) {
@@ -392,6 +404,7 @@ export const __testing = {
formatToolFailuresSection,
computeAdaptiveChunkRatio,
isOversizedForSummary,
readWorkspaceContextForSummary,
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,

View File

@@ -0,0 +1,76 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_AGENTS_FILENAME } from "../workspace.js";
import { ensureSandboxWorkspace } from "./workspace.js";
const tempRoots: string[] = [];
async function makeTempRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-workspace-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
});
describe("ensureSandboxWorkspace", () => {
it("seeds regular bootstrap files from the source workspace", async () => {
const root = await makeTempRoot();
const seed = path.join(root, "seed");
const sandbox = path.join(root, "sandbox");
await fs.mkdir(seed, { recursive: true });
await fs.writeFile(path.join(seed, DEFAULT_AGENTS_FILENAME), "seeded-agents", "utf-8");
await ensureSandboxWorkspace(sandbox, seed, true);
await expect(fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8")).resolves.toBe(
"seeded-agents",
);
});
it.runIf(process.platform !== "win32")("skips symlinked bootstrap seed files", async () => {
const root = await makeTempRoot();
const seed = path.join(root, "seed");
const sandbox = path.join(root, "sandbox");
const outside = path.join(root, "outside-secret.txt");
await fs.mkdir(seed, { recursive: true });
await fs.writeFile(outside, "secret", "utf-8");
await fs.symlink(outside, path.join(seed, DEFAULT_AGENTS_FILENAME));
await ensureSandboxWorkspace(sandbox, seed, true);
await expect(
fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8"),
).rejects.toBeDefined();
});
it.runIf(process.platform !== "win32")("skips hardlinked bootstrap seed files", async () => {
const root = await makeTempRoot();
const seed = path.join(root, "seed");
const sandbox = path.join(root, "sandbox");
const outside = path.join(root, "outside-agents.txt");
const linkedSeed = path.join(seed, DEFAULT_AGENTS_FILENAME);
await fs.mkdir(seed, { recursive: true });
await fs.writeFile(outside, "outside", "utf-8");
try {
await fs.link(outside, linkedSeed);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw error;
}
await ensureSandboxWorkspace(sandbox, seed, true);
await expect(
fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8"),
).rejects.toBeDefined();
});
});

View File

@@ -1,5 +1,7 @@
import syncFs from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { resolveUserPath } from "../../utils.js";
import {
DEFAULT_AGENTS_FILENAME,
@@ -36,8 +38,20 @@ export async function ensureSandboxWorkspace(
await fs.access(dest);
} catch {
try {
const content = await fs.readFile(src, "utf-8");
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
const opened = await openBoundaryFile({
absolutePath: src,
rootPath: seed,
boundaryLabel: "sandbox seed workspace",
});
if (!opened.ok) {
continue;
}
try {
const content = syncFs.readFileSync(opened.fd, "utf-8");
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
} finally {
syncFs.closeSync(opened.fd);
}
} catch {
// ignore missing seed file
}