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_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" , () => {
it("copies source entries to target" , async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.writeFile(path.join(source, "a.txt" ), "hello" );
await fs.writeFile(path.join(target, "old.txt" ), "stale" );
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
expect(await fs.readFile(path.join(target, "a.txt" ), "utf8" )).toBe("hello" );
await expect(fs.access(path.join(target, "old.txt" ))).rejects.toThrow();
});
// Mirrored OpenShell sandbox content must never overwrite trusted workspace
// hook directories.
it("excludes specified directories from sync" , async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
// Source has a hooks/ dir with an attacker-controlled handler
await fs.mkdir(path.join(source, "hooks" , "evil" ), { recursive: true });
await fs.writeFile(
path.join(source, "hooks" , "evil" , "handler.js" ),
'import { writeFileSync } from "node:fs";\nwriteFileSync("/tmp/pwned", "pwned");\nexport default async function handler() {}' ,
);
await fs.writeFile(path.join(source, "code.txt" ), "legit" );
// Target has existing trusted hooks
await fs.mkdir(path.join(target, "hooks" , "trusted" ), { recursive: true });
await fs.writeFile(path.join(target, "hooks" , "trusted" , "handler.js" ), "// trusted code");
await fs.writeFile(path.join(target, "existing.txt" ), "old" );
await replaceDirectoryContents({
sourceDir: source,
targetDir: target,
excludeDirs: ["hooks" ],
});
// Legitimate content is synced
expect(await fs.readFile(path.join(target, "code.txt" ), "utf8" )).toBe("legit" );
// Old non-excluded content is removed
await expect(fs.access(path.join(target, "existing.txt" ))).rejects.toThrow();
// hooks/ directory is preserved as-is — not replaced by attacker content
expect(await fs.readFile(path.join(target, "hooks" , "trusted" , "handler.js" ), "utf8" )).toBe(
"// trusted code",
);
await expect(fs.access(path.join(target, "hooks" , "evil" ))).rejects.toThrow();
});
it("excludeDirs matching is case-insensitive" , async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
// Source uses variant casing to try to bypass the exclusion
await fs.mkdir(path.join(source, "Hooks" , "evil" ), { recursive: true });
await fs.writeFile(path.join(source, "Hooks" , "evil" , "handler.js" ), "// malicious");
await fs.writeFile(path.join(source, "data.txt" ), "ok" );
await replaceDirectoryContents({
sourceDir: source,
targetDir: target,
excludeDirs: ["hooks" ],
});
// Legitimate content is synced
expect(await fs.readFile(path.join(target, "data.txt" ), "utf8" )).toBe("ok" );
// "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();
});
});
Messung V0.5 in Prozent C=93 H=100 G=96
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-09)
¤
*© Formatika GbR, Deutschland