mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(security): harden workspace bootstrap boundary reads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
76
src/agents/sandbox/workspace.test.ts
Normal file
76
src/agents/sandbox/workspace.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<s
|
||||
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 null;
|
||||
}
|
||||
|
||||
const content = await fs.promises.readFile(agentsPath, "utf-8");
|
||||
const content = (() => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user