OpenShell: harden mirror sync boundaries (#57693)

* OpenShell: harden mirror sync boundaries

* OpenShell: polish mirror hardening tests

* OpenShell: preserve trusted mirror symlinks

* OpenShell: bound mirror fs work globally
This commit is contained in:
Jacob Tomlinson 2026-03-30 06:51:44 -07:00 committed by GitHub
parent a4e447a16e
commit 3b9dab0ece
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 262 additions and 47 deletions

View File

@ -27,7 +27,11 @@ import {
} from "./cli.js";
import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js";
import { createOpenShellFsBridge } from "./fs-bridge.js";
import { replaceDirectoryContents } from "./mirror.js";
import {
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
replaceDirectoryContents,
stageDirectoryContents,
} from "./mirror.js";
type CreateOpenShellSandboxBackendFactoryParams = {
pluginConfig: ResolvedOpenShellPluginConfig;
@ -293,6 +297,14 @@ class OpenShellSandboxBackendImpl {
});
return;
}
if (stats.isSymbolicLink()) {
await this.runRemoteShellScript({
script: 'rm -rf -- "$1"',
args: [remotePath],
allowFailure: true,
});
return;
}
if (stats.isDirectory()) {
await this.runRemoteShellScript({
script: 'mkdir -p -- "$1"',
@ -421,9 +433,9 @@ class OpenShellSandboxBackendImpl {
await replaceDirectoryContents({
sourceDir: tmpDir,
targetDir: this.params.createParams.workspaceDir,
// Never sync hooks/ from the remote sandbox — mirrored content must not
// become trusted workspace hook code on the host.
excludeDirs: ["hooks"],
// Never sync trusted host hook directories or repository metadata from
// the remote sandbox.
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
@ -431,20 +443,33 @@ class OpenShellSandboxBackendImpl {
}
private async uploadPathToRemote(localPath: string, remotePath: string): Promise<void> {
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"upload",
"--no-git-ignore",
this.params.execContext.sandboxName,
localPath,
remotePath,
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
const tmpDir = await fs.mkdtemp(
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-upload-"),
);
try {
// Stage a symlink-free snapshot so upload never dereferences host paths
// outside the mirrored workspace tree.
await stageDirectoryContents({
sourceDir: localPath,
targetDir: tmpDir,
});
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"upload",
"--no-git-ignore",
this.params.execContext.sandboxName,
tmpDir,
remotePath,
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
}
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}

View File

@ -2,22 +2,26 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { replaceDirectoryContents } from "./mirror.js";
import {
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
replaceDirectoryContents,
stageDirectoryContents,
} from "./mirror.js";
const dirs: string[] = [];
async function makeTmpDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mirror-test-"));
dirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(dirs.map((d) => fs.rm(d, { recursive: true, force: true })));
dirs.length = 0;
});
describe("replaceDirectoryContents", () => {
const dirs: string[] = [];
async function makeTmpDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mirror-test-"));
dirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(dirs.map((d) => fs.rm(d, { recursive: true, force: true })));
dirs.length = 0;
});
it("copies source entries to target", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
@ -89,4 +93,90 @@ describe("replaceDirectoryContents", () => {
// "Hooks" (variant case) must still be excluded
await expect(fs.access(path.join(target, "Hooks"))).rejects.toThrow();
});
it("preserves default excluded directories and repository metadata", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.mkdir(path.join(source, "hooks"), { recursive: true });
await fs.writeFile(path.join(source, "hooks", "pre-commit"), "malicious");
await fs.mkdir(path.join(source, "git-hooks"), { recursive: true });
await fs.writeFile(path.join(source, "git-hooks", "pre-commit"), "malicious");
await fs.mkdir(path.join(source, ".git", "hooks"), { recursive: true });
await fs.writeFile(path.join(source, ".git", "hooks", "post-checkout"), "malicious");
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.mkdir(path.join(target, "hooks"), { recursive: true });
await fs.writeFile(path.join(target, "hooks", "trusted"), "trusted");
await fs.mkdir(path.join(target, "git-hooks"), { recursive: true });
await fs.writeFile(path.join(target, "git-hooks", "trusted"), "trusted");
await fs.mkdir(path.join(target, ".git"), { recursive: true });
await fs.writeFile(path.join(target, ".git", "HEAD"), "ref: refs/heads/main\n");
await replaceDirectoryContents({
sourceDir: source,
targetDir: target,
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
});
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readFile(path.join(target, "hooks", "trusted"), "utf8")).toBe("trusted");
expect(await fs.readFile(path.join(target, "git-hooks", "trusted"), "utf8")).toBe("trusted");
expect(await fs.readFile(path.join(target, ".git", "HEAD"), "utf8")).toBe(
"ref: refs/heads/main\n",
);
await expect(fs.access(path.join(target, ".git", "hooks", "post-checkout"))).rejects.toThrow();
});
it("skips symbolic links when copying into the host workspace", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.mkdir(path.join(source, "nested"), { recursive: true });
await fs.writeFile(path.join(source, "nested", "file.txt"), "nested");
await fs.symlink("/tmp/host-secret", path.join(source, "escaped-link"));
await fs.symlink("/tmp/host-secret-dir", path.join(source, "nested", "escaped-dir"));
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readFile(path.join(target, "nested", "file.txt"), "utf8")).toBe("nested");
await expect(fs.lstat(path.join(target, "escaped-link"))).rejects.toThrow();
await expect(fs.lstat(path.join(target, "nested", "escaped-dir"))).rejects.toThrow();
});
it("preserves existing trusted host symlinks", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.writeFile(path.join(source, "linked-entry"), "remote-plain-file");
await fs.symlink("/tmp/trusted-host-target", path.join(target, "linked-entry"));
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readlink(path.join(target, "linked-entry"))).toBe("/tmp/trusted-host-target");
});
});
describe("stageDirectoryContents", () => {
it("stages upload content without symbolic links", async () => {
const source = await makeTmpDir();
const staged = await makeTmpDir();
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.mkdir(path.join(source, "nested"), { recursive: true });
await fs.writeFile(path.join(source, "nested", "file.txt"), "nested");
await fs.symlink("/tmp/host-secret", path.join(source, "escaped-link"));
await fs.symlink("/tmp/host-secret-dir", path.join(source, "nested", "escaped-dir"));
await stageDirectoryContents({ sourceDir: source, targetDir: staged });
expect(await fs.readFile(path.join(staged, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readFile(path.join(staged, "nested", "file.txt"), "utf8")).toBe("nested");
await expect(fs.lstat(path.join(staged, "escaped-link"))).rejects.toThrow();
await expect(fs.lstat(path.join(staged, "nested", "escaped-dir"))).rejects.toThrow();
});
});

View File

@ -1,37 +1,137 @@
import fs from "node:fs/promises";
import path from "node:path";
export const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = ["hooks", "git-hooks", ".git"] as const;
const COPY_TREE_FS_CONCURRENCY = 16;
function createExcludeMatcher(excludeDirs?: readonly string[]) {
const excluded = new Set((excludeDirs ?? []).map((d) => d.toLowerCase()));
return (name: string) => excluded.has(name.toLowerCase());
}
function createConcurrencyLimiter(limit: number) {
let active = 0;
const queue: Array<() => void> = [];
const release = () => {
active -= 1;
queue.shift()?.();
};
return async <T>(task: () => Promise<T>): Promise<T> => {
if (active >= limit) {
await new Promise<void>((resolve) => {
queue.push(resolve);
});
}
active += 1;
try {
return await task();
} finally {
release();
}
};
}
const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
async function lstatIfExists(targetPath: string) {
return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
}
async function copyTreeWithoutSymlinks(params: {
sourcePath: string;
targetPath: string;
preserveTargetSymlinks?: boolean;
}): Promise<void> {
const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
// Mirror sync only carries regular files and directories across the
// host/sandbox boundary. Symlinks and special files are dropped.
if (stats.isSymbolicLink()) {
return;
}
const targetStats = await lstatIfExists(params.targetPath);
if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) {
return;
}
if (stats.isDirectory()) {
await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
await Promise.all(
entries.map(async (entry) => {
await copyTreeWithoutSymlinks({
sourcePath: path.join(params.sourcePath, entry),
targetPath: path.join(params.targetPath, entry),
preserveTargetSymlinks: params.preserveTargetSymlinks,
});
}),
);
return;
}
if (stats.isFile()) {
await runLimitedFs(
async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }),
);
await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
}
}
export async function replaceDirectoryContents(params: {
sourceDir: string;
targetDir: string;
/** Top-level directory names to exclude from sync (preserved in target, skipped from source). */
excludeDirs?: string[];
excludeDirs?: readonly string[];
}): Promise<void> {
// Case-insensitive matching: on macOS/Windows the filesystem is typically
// case-insensitive, so "Hooks" would resolve to the same directory as "hooks".
const excluded = new Set((params.excludeDirs ?? []).map((d) => d.toLowerCase()));
const isExcluded = (name: string) => excluded.has(name.toLowerCase());
const isExcluded = createExcludeMatcher(params.excludeDirs);
await fs.mkdir(params.targetDir, { recursive: true });
const existing = await fs.readdir(params.targetDir);
await Promise.all(
existing
.filter((entry) => !isExcluded(entry))
.map((entry) =>
fs.rm(path.join(params.targetDir, entry), {
recursive: true,
force: true,
}),
),
.map(async (entry) => {
const targetPath = path.join(params.targetDir, entry);
const stats = await lstatIfExists(targetPath);
if (stats?.isSymbolicLink()) {
return;
}
await runLimitedFs(
async () =>
await fs.rm(targetPath, {
recursive: true,
force: true,
}),
);
}),
);
const sourceEntries = await fs.readdir(params.sourceDir);
for (const entry of sourceEntries) {
if (isExcluded(entry)) {
continue;
}
await fs.cp(path.join(params.sourceDir, entry), path.join(params.targetDir, entry), {
recursive: true,
force: true,
dereference: false,
await copyTreeWithoutSymlinks({
sourcePath: path.join(params.sourceDir, entry),
targetPath: path.join(params.targetDir, entry),
preserveTargetSymlinks: true,
});
}
}
export async function stageDirectoryContents(params: {
sourceDir: string;
targetDir: string;
/** Top-level directory names to exclude from the staged upload. */
excludeDirs?: readonly string[];
}): Promise<void> {
const isExcluded = createExcludeMatcher(params.excludeDirs);
await fs.mkdir(params.targetDir, { recursive: true });
const sourceEntries = await fs.readdir(params.sourceDir);
for (const entry of sourceEntries) {
if (isExcluded(entry)) {
continue;
}
await copyTreeWithoutSymlinks({
sourcePath: path.join(params.sourceDir, entry),
targetPath: path.join(params.targetDir, entry),
});
}
}