Skip to content

Commit 07b16d5

Browse files
committed
fix(security): harden workspace bootstrap boundary reads
1 parent 67b2dde commit 07b16d5

File tree

8 files changed

+190
-7
lines changed

8 files changed

+190
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
4646

4747
### Fixes
4848

49+
- 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.
4950
- 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.
5051
- 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.
5152
- 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.

docs/concepts/agent-workspace.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac
3838

3939
`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the
4040
workspace and seed the bootstrap files if they are missing.
41+
Sandbox seed copies only accept regular in-workspace files; symlink/hardlink
42+
aliases that resolve outside the source workspace are ignored.
4143

4244
If you already manage the workspace files yourself, you can disable bootstrap
4345
file creation:

src/agents/pi-extensions/compaction-safeguard.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
14
import type { AgentMessage } from "@mariozechner/pi-agent-core";
25
import type { Api, Model } from "@mariozechner/pi-ai";
36
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -13,6 +16,7 @@ const {
1316
formatToolFailuresSection,
1417
computeAdaptiveChunkRatio,
1518
isOversizedForSummary,
19+
readWorkspaceContextForSummary,
1620
BASE_CHUNK_RATIO,
1721
MIN_CHUNK_RATIO,
1822
SAFETY_MARGIN,
@@ -484,3 +488,41 @@ describe("compaction-safeguard double-compaction guard", () => {
484488
expect(getApiKeyMock).toHaveBeenCalled();
485489
});
486490
});
491+
492+
describe("readWorkspaceContextForSummary", () => {
493+
it.runIf(process.platform !== "win32")(
494+
"returns empty when AGENTS.md is a symlink escape",
495+
async () => {
496+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
497+
const prevCwd = process.cwd();
498+
try {
499+
const outside = path.join(root, "outside-secret.txt");
500+
fs.writeFileSync(outside, "secret");
501+
fs.symlinkSync(outside, path.join(root, "AGENTS.md"));
502+
process.chdir(root);
503+
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
504+
} finally {
505+
process.chdir(prevCwd);
506+
fs.rmSync(root, { recursive: true, force: true });
507+
}
508+
},
509+
);
510+
511+
it.runIf(process.platform !== "win32")(
512+
"returns empty when AGENTS.md is a hardlink alias",
513+
async () => {
514+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
515+
const prevCwd = process.cwd();
516+
try {
517+
const outside = path.join(root, "outside-secret.txt");
518+
fs.writeFileSync(outside, "secret");
519+
fs.linkSync(outside, path.join(root, "AGENTS.md"));
520+
process.chdir(root);
521+
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
522+
} finally {
523+
process.chdir(prevCwd);
524+
fs.rmSync(root, { recursive: true, force: true });
525+
}
526+
},
527+
);
528+
});

src/agents/pi-extensions/compaction-safeguard.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33
import type { AgentMessage } from "@mariozechner/pi-agent-core";
44
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
55
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
6+
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
67
import { createSubsystemLogger } from "../../logging/subsystem.js";
78
import {
89
BASE_CHUNK_RATIO,
@@ -169,11 +170,22 @@ async function readWorkspaceContextForSummary(): Promise<string> {
169170
const agentsPath = path.join(workspaceDir, "AGENTS.md");
170171

171172
try {
172-
if (!fs.existsSync(agentsPath)) {
173+
const opened = await openBoundaryFile({
174+
absolutePath: agentsPath,
175+
rootPath: workspaceDir,
176+
boundaryLabel: "workspace root",
177+
});
178+
if (!opened.ok) {
173179
return "";
174180
}
175181

176-
const content = await fs.promises.readFile(agentsPath, "utf-8");
182+
const content = (() => {
183+
try {
184+
return fs.readFileSync(opened.fd, "utf-8");
185+
} finally {
186+
fs.closeSync(opened.fd);
187+
}
188+
})();
177189
const sections = extractSections(content, ["Session Startup", "Red Lines"]);
178190

179191
if (sections.length === 0) {
@@ -392,6 +404,7 @@ export const __testing = {
392404
formatToolFailuresSection,
393405
computeAdaptiveChunkRatio,
394406
isOversizedForSummary,
407+
readWorkspaceContextForSummary,
395408
BASE_CHUNK_RATIO,
396409
MIN_CHUNK_RATIO,
397410
SAFETY_MARGIN,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { DEFAULT_AGENTS_FILENAME } from "../workspace.js";
6+
import { ensureSandboxWorkspace } from "./workspace.js";
7+
8+
const tempRoots: string[] = [];
9+
10+
async function makeTempRoot(): Promise<string> {
11+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-workspace-"));
12+
tempRoots.push(root);
13+
return root;
14+
}
15+
16+
afterEach(async () => {
17+
await Promise.all(
18+
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
19+
);
20+
});
21+
22+
describe("ensureSandboxWorkspace", () => {
23+
it("seeds regular bootstrap files from the source workspace", async () => {
24+
const root = await makeTempRoot();
25+
const seed = path.join(root, "seed");
26+
const sandbox = path.join(root, "sandbox");
27+
await fs.mkdir(seed, { recursive: true });
28+
await fs.writeFile(path.join(seed, DEFAULT_AGENTS_FILENAME), "seeded-agents", "utf-8");
29+
30+
await ensureSandboxWorkspace(sandbox, seed, true);
31+
32+
await expect(fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8")).resolves.toBe(
33+
"seeded-agents",
34+
);
35+
});
36+
37+
it.runIf(process.platform !== "win32")("skips symlinked bootstrap seed files", async () => {
38+
const root = await makeTempRoot();
39+
const seed = path.join(root, "seed");
40+
const sandbox = path.join(root, "sandbox");
41+
const outside = path.join(root, "outside-secret.txt");
42+
await fs.mkdir(seed, { recursive: true });
43+
await fs.writeFile(outside, "secret", "utf-8");
44+
await fs.symlink(outside, path.join(seed, DEFAULT_AGENTS_FILENAME));
45+
46+
await ensureSandboxWorkspace(sandbox, seed, true);
47+
48+
await expect(
49+
fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8"),
50+
).rejects.toBeDefined();
51+
});
52+
53+
it.runIf(process.platform !== "win32")("skips hardlinked bootstrap seed files", async () => {
54+
const root = await makeTempRoot();
55+
const seed = path.join(root, "seed");
56+
const sandbox = path.join(root, "sandbox");
57+
const outside = path.join(root, "outside-agents.txt");
58+
const linkedSeed = path.join(seed, DEFAULT_AGENTS_FILENAME);
59+
await fs.mkdir(seed, { recursive: true });
60+
await fs.writeFile(outside, "outside", "utf-8");
61+
try {
62+
await fs.link(outside, linkedSeed);
63+
} catch (error) {
64+
if ((error as NodeJS.ErrnoException).code === "EXDEV") {
65+
return;
66+
}
67+
throw error;
68+
}
69+
70+
await ensureSandboxWorkspace(sandbox, seed, true);
71+
72+
await expect(
73+
fs.readFile(path.join(sandbox, DEFAULT_AGENTS_FILENAME), "utf-8"),
74+
).rejects.toBeDefined();
75+
});
76+
});

src/agents/sandbox/workspace.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import syncFs from "node:fs";
12
import fs from "node:fs/promises";
23
import path from "node:path";
4+
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
35
import { resolveUserPath } from "../../utils.js";
46
import {
57
DEFAULT_AGENTS_FILENAME,
@@ -36,8 +38,20 @@ export async function ensureSandboxWorkspace(
3638
await fs.access(dest);
3739
} catch {
3840
try {
39-
const content = await fs.readFile(src, "utf-8");
40-
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
41+
const opened = await openBoundaryFile({
42+
absolutePath: src,
43+
rootPath: seed,
44+
boundaryLabel: "sandbox seed workspace",
45+
});
46+
if (!opened.ok) {
47+
continue;
48+
}
49+
try {
50+
const content = syncFs.readFileSync(opened.fd, "utf-8");
51+
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
52+
} finally {
53+
syncFs.closeSync(opened.fd);
54+
}
4155
} catch {
4256
// ignore missing seed file
4357
}

src/auto-reply/reply/post-compaction-context.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,28 @@ Never do Y.
166166
expect(result).toContain("Rule 2");
167167
expect(result).not.toContain("Other Section");
168168
});
169+
170+
it.runIf(process.platform !== "win32")(
171+
"returns null when AGENTS.md is a symlink escaping workspace",
172+
async () => {
173+
const outside = path.join(tmpDir, "outside-secret.txt");
174+
fs.writeFileSync(outside, "secret");
175+
fs.symlinkSync(outside, path.join(tmpDir, "AGENTS.md"));
176+
177+
const result = await readPostCompactionContext(tmpDir);
178+
expect(result).toBeNull();
179+
},
180+
);
181+
182+
it.runIf(process.platform !== "win32")(
183+
"returns null when AGENTS.md is a hardlink alias",
184+
async () => {
185+
const outside = path.join(tmpDir, "outside-secret.txt");
186+
fs.writeFileSync(outside, "secret");
187+
fs.linkSync(outside, path.join(tmpDir, "AGENTS.md"));
188+
189+
const result = await readPostCompactionContext(tmpDir);
190+
expect(result).toBeNull();
191+
},
192+
);
169193
});

src/auto-reply/reply/post-compaction-context.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3+
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
34

45
const MAX_CONTEXT_CHARS = 3000;
56

@@ -11,11 +12,21 @@ export async function readPostCompactionContext(workspaceDir: string): Promise<s
1112
const agentsPath = path.join(workspaceDir, "AGENTS.md");
1213

1314
try {
14-
if (!fs.existsSync(agentsPath)) {
15+
const opened = await openBoundaryFile({
16+
absolutePath: agentsPath,
17+
rootPath: workspaceDir,
18+
boundaryLabel: "workspace root",
19+
});
20+
if (!opened.ok) {
1521
return null;
1622
}
17-
18-
const content = await fs.promises.readFile(agentsPath, "utf-8");
23+
const content = (() => {
24+
try {
25+
return fs.readFileSync(opened.fd, "utf-8");
26+
} finally {
27+
fs.closeSync(opened.fd);
28+
}
29+
})();
1930

2031
// Extract "## Session Startup" and "## Red Lines" sections
2132
// Each section ends at the next "## " heading or end of file

0 commit comments

Comments
 (0)