diff --git a/CHANGELOG.md b/CHANGELOG.md index eb229d53383..0ac6002af86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting. - Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths. - Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale. - Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting. diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 20b2fffa319..ff55f241bcd 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -38,6 +38,8 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac `openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the workspace and seed the bootstrap files if they are missing. +Sandbox seed copies only accept regular in-workspace files; symlink/hardlink +aliases that resolve outside the source workspace are ignored. If you already manage the workspace files yourself, you can disable bootstrap file creation: diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 60d3858c5d0..81e746fc7bc 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -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 }); + } + }, + ); +}); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 19a9366fcb6..1134d68c906 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -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 { 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, diff --git a/src/agents/sandbox/workspace.test.ts b/src/agents/sandbox/workspace.test.ts new file mode 100644 index 00000000000..88badcaddb8 --- /dev/null +++ b/src/agents/sandbox/workspace.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/agents/sandbox/workspace.ts b/src/agents/sandbox/workspace.ts index e2ce3008ce3..cca63819fde 100644 --- a/src/agents/sandbox/workspace.ts +++ b/src/agents/sandbox/workspace.ts @@ -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 } diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 003da9deb26..7adb4610619 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -166,4 +166,28 @@ Never do Y. expect(result).toContain("Rule 2"); expect(result).not.toContain("Other Section"); }); + + it.runIf(process.platform !== "win32")( + "returns null when AGENTS.md is a symlink escaping workspace", + async () => { + const outside = path.join(tmpDir, "outside-secret.txt"); + fs.writeFileSync(outside, "secret"); + fs.symlinkSync(outside, path.join(tmpDir, "AGENTS.md")); + + const result = await readPostCompactionContext(tmpDir); + expect(result).toBeNull(); + }, + ); + + it.runIf(process.platform !== "win32")( + "returns null when AGENTS.md is a hardlink alias", + async () => { + const outside = path.join(tmpDir, "outside-secret.txt"); + fs.writeFileSync(outside, "secret"); + fs.linkSync(outside, path.join(tmpDir, "AGENTS.md")); + + const result = await readPostCompactionContext(tmpDir); + expect(result).toBeNull(); + }, + ); }); diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 1c455e91893..7f627d1d153 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { openBoundaryFile } from "../../infra/boundary-file-read.js"; const MAX_CONTEXT_CHARS = 3000; @@ -11,11 +12,21 @@ export async function readPostCompactionContext(workspaceDir: string): Promise { + try { + return fs.readFileSync(opened.fd, "utf-8"); + } finally { + fs.closeSync(opened.fd); + } + })(); // Extract "## Session Startup" and "## Red Lines" sections // Each section ends at the next "## " heading or end of file