fix(sandbox): support Windows drive-letter bind sources

Accept drive-absolute Windows sandbox Docker bind sources in config and runtime validation while keeping blocked-path and allowed-root comparisons case-insensitive for Windows drive paths.

Also remove a stale WhatsApp setup import that blocked extension lint after the rebase.

Co-authored-by: 6607changchun <84566142+6607changchun@users.noreply.github.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
This commit is contained in:
6607changchun
2026-05-05 12:39:56 +08:00
committed by GitHub
parent 35266879de
commit d02fbc6116
9 changed files with 167 additions and 25 deletions

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda.
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.
### Fixes

View File

@@ -1,7 +1,6 @@
import path from "node:path";
import {
DEFAULT_ACCOUNT_ID,
normalizeE164,
pathExists,
splitSetupEntries,
type DmPolicy,

View File

@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
getSandboxHostPathPolicyKey,
isSandboxHostPathAbsolute,
normalizeSandboxHostPath,
resolveSandboxHostPathViaExistingAncestor,
} from "./host-paths.js";
@@ -11,6 +13,33 @@ describe("normalizeSandboxHostPath", () => {
it("normalizes dot segments and strips trailing slash", () => {
expect(normalizeSandboxHostPath("/tmp/a/../b//")).toBe("/tmp/b");
});
it("normalizes Windows drive-letter paths without losing the drive root", () => {
expect(normalizeSandboxHostPath("c:\\Users\\Kai\\..\\Project\\")).toBe("C:/Users/Project");
expect(normalizeSandboxHostPath("d:/")).toBe("D:/");
});
});
describe("isSandboxHostPathAbsolute", () => {
it("accepts POSIX and drive-absolute Windows paths", () => {
expect(isSandboxHostPathAbsolute("/tmp/project")).toBe(true);
expect(isSandboxHostPathAbsolute("C:/Users/kai/project")).toBe(true);
expect(isSandboxHostPathAbsolute("C:\\Users\\kai\\project")).toBe(true);
});
it("rejects relative paths, named volumes, and drive-relative Windows paths", () => {
expect(isSandboxHostPathAbsolute("relative/path")).toBe(false);
expect(isSandboxHostPathAbsolute("my-volume")).toBe(false);
expect(isSandboxHostPathAbsolute("C:relative\\path")).toBe(false);
});
});
describe("getSandboxHostPathPolicyKey", () => {
it("compares Windows drive-letter paths case-insensitively", () => {
expect(getSandboxHostPathPolicyKey("c:\\Users\\Kai\\.SSH\\config")).toBe(
"c:/users/kai/.ssh/config",
);
});
});
describe("resolveSandboxHostPathViaExistingAncestor", () => {
@@ -18,6 +47,16 @@ describe("resolveSandboxHostPathViaExistingAncestor", () => {
expect(resolveSandboxHostPathViaExistingAncestor("relative/path")).toBe("relative/path");
});
it("normalizes Windows paths without resolving them through POSIX cwd on non-Windows hosts", () => {
if (process.platform === "win32") {
return;
}
expect(resolveSandboxHostPathViaExistingAncestor("C:/Users/kai/project")).toBe(
"C:/Users/kai/project",
);
});
it("resolves symlink parents when the final leaf does not exist", () => {
if (process.platform === "win32") {
return;

View File

@@ -19,16 +19,42 @@ function stripWindowsNamespacePrefix(input: string): string {
return input;
}
export function isWindowsDriveAbsolutePath(raw: string): boolean {
return /^[A-Za-z]:[\\/]/.test(stripWindowsNamespacePrefix(raw.trim()));
}
export function isSandboxHostPathAbsolute(raw: string): boolean {
const trimmed = stripWindowsNamespacePrefix(raw.trim());
return trimmed.startsWith("/") || isWindowsDriveAbsolutePath(trimmed);
}
/**
* Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
* Normalize a host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
* Windows drive-letter paths preserve the drive root and uppercase the drive letter.
*/
export function normalizeSandboxHostPath(raw: string): string {
const trimmed = stripWindowsNamespacePrefix(raw.trim());
if (!trimmed) {
return "/";
}
const normalized = posix.normalize(trimmed.replaceAll("\\", "/"));
return normalized.replace(/\/+$/, "") || "/";
let normalTrimmed = trimmed.replaceAll("\\", "/");
if (isWindowsDriveAbsolutePath(normalTrimmed)) {
normalTrimmed = normalTrimmed.charAt(0).toUpperCase() + normalTrimmed.slice(1);
}
const normalized = posix.normalize(normalTrimmed);
const withoutTrailingSlash = normalized.replace(/\/+$/, "") || "/";
if (/^[A-Z]:$/.test(withoutTrailingSlash)) {
return `${withoutTrailingSlash}/`;
}
return withoutTrailingSlash;
}
export function getSandboxHostPathPolicyKey(raw: string): string {
const normalized = normalizeSandboxHostPath(raw);
if (isWindowsDriveAbsolutePath(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
/**
@@ -36,8 +62,11 @@ export function normalizeSandboxHostPath(raw: string): string {
* even when the final source leaf does not exist yet.
*/
export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): string {
if (!sourcePath.startsWith("/")) {
if (!isSandboxHostPathAbsolute(sourcePath)) {
return sourcePath;
}
if (isWindowsDriveAbsolutePath(sourcePath) && process.platform !== "win32") {
return normalizeSandboxHostPath(sourcePath);
}
return normalizeSandboxHostPath(resolvePathViaExistingAncestorSync(sourcePath));
}

View File

@@ -174,6 +174,25 @@ describe("validateBindMounts", () => {
expect(() => validateBindMounts(["/home/tester/.netrc:/mnt/netrc:ro"])).toThrow(/blocked path/);
});
it("allows drive-absolute Windows bind sources", () => {
expect(() => validateBindMounts(["D:/data/openclaw/src:/src:ro"])).not.toThrow();
expect(() => validateBindMounts(["D:\\data\\openclaw\\output:/output:rw"])).not.toThrow();
});
it("compares Windows allowed roots case-insensitively", () => {
expect(() =>
validateBindMounts(["d:/DATA/OpenClaw/src:/src:ro"], {
allowedSourceRoots: ["D:/data/openclaw"],
}),
).not.toThrow();
expect(() =>
validateBindMounts(["D:/other/project:/src:ro"], {
allowedSourceRoots: ["d:/data/openclaw"],
}),
).toThrow(/outside allowed roots/);
});
it("blocks credential binds through canonical home aliases", () => {
if (process.platform === "win32") {
return;
@@ -193,14 +212,7 @@ describe("validateBindMounts", () => {
it("blocks symlink escapes into blocked directories", () => {
if (process.platform === "win32") {
// Symlinks to non-existent targets like /etc require
// SeCreateSymbolicLinkPrivilege on Windows. The Windows branch of this
// test does not need a real symlink — it only asserts that Windows source
// paths are rejected as non-POSIX.
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
const fakePath = join(dir, "etc-link", "passwd");
const run = () => validateBindMounts([`${fakePath}:/mnt/passwd:ro`]);
expect(run).toThrow(/non-absolute source path/);
// Symlink setup for blocked POSIX targets like /etc is POSIX-only.
return;
}
@@ -213,7 +225,7 @@ describe("validateBindMounts", () => {
it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => {
if (process.platform === "win32") {
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
// Windows symlink semantics differ; POSIX symlink escape coverage runs on POSIX hosts.
return;
}
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
@@ -233,7 +245,7 @@ describe("validateBindMounts", () => {
it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => {
if (process.platform === "win32") {
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
// Symlink setup for blocked POSIX targets like /var/run is POSIX-only.
return;
}
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));

View File

@@ -12,6 +12,8 @@ import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"
import { splitSandboxBindSpec } from "./bind-spec.js";
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import {
getSandboxHostPathPolicyKey,
isSandboxHostPathAbsolute,
normalizeSandboxHostPath,
resolveSandboxHostPathViaExistingAncestor,
} from "./host-paths.js";
@@ -101,6 +103,7 @@ function parseBindTargetPath(bind: string): string {
/**
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
* If it starts with the drive letter, convert it to the upper case.
*/
function normalizeHostPath(raw: string): string {
return normalizeSandboxHostPath(raw);
@@ -115,10 +118,9 @@ function normalizeHostPath(raw: string): string {
*/
export function getBlockedBindReason(bind: string): BlockedBindReason | null {
const sourceRaw = parseBindSourcePath(bind);
if (!sourceRaw.startsWith("/")) {
if (!isSandboxHostPathAbsolute(sourceRaw)) {
return { kind: "non_absolute", sourcePath: sourceRaw };
}
const normalized = normalizeHostPath(sourceRaw);
const blockedHostPaths = getBlockedHostPaths();
const directReason = getBlockedReasonForSourcePath(normalized, blockedHostPaths);
@@ -141,8 +143,10 @@ function getBlockedReasonForSourcePath(
if (sourceNormalized === "/") {
return { kind: "covers", blockedPath: "/" };
}
const sourceKey = getSandboxHostPathPolicyKey(sourceNormalized);
for (const blocked of blockedHostPaths) {
if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) {
const blockedKey = getSandboxHostPathPolicyKey(blocked);
if (sourceKey === blockedKey || sourceKey.startsWith(`${blockedKey}/`)) {
return { kind: "targets", blockedPath: blocked };
}
}
@@ -193,7 +197,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] {
}
const normalized = roots
.map((entry) => entry.trim())
.filter((entry) => entry.startsWith("/"))
.filter(isSandboxHostPathAbsolute)
.map(normalizeHostPath);
const expanded = new Set<string>();
for (const root of normalized) {
@@ -210,7 +214,9 @@ function isPathInsidePosix(root: string, target: string): boolean {
if (root === "/") {
return true;
}
return target === root || target.startsWith(`${root}/`);
const rootKey = getSandboxHostPathPolicyKey(root);
const targetKey = getSandboxHostPathPolicyKey(target);
return targetKey === rootKey || targetKey.startsWith(`${rootKey}/`);
}
function getOutsideAllowedRootsReason(
@@ -274,7 +280,7 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso
if (params.reason.kind === "non_absolute") {
return new Error(
`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` +
`"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`,
`"${params.reason.sourcePath}". Only absolute POSIX or Windows drive-letter paths are supported for sandbox binds.`,
);
}
if (params.reason.kind === "outside_allowed_roots") {

View File

@@ -62,6 +62,42 @@ describe("sandbox docker config", () => {
}
});
it("accepts Windows drive-letter binds in sandbox.docker config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
binds: ["D:/data/openclaw/src:/src:ro", "D:\\data\\openclaw\\output:/output:rw"],
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
"D:/data/openclaw/src:/src:ro",
"D:\\data\\openclaw\\output:/output:rw",
]);
}
});
it("rejects drive-relative Windows binds in sandbox.docker config", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
binds: ["D:relative\\path:/src:ro"],
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("accepts non-empty Docker GPU passthrough config", () => {
const res = validateConfigObject({
agents: {

View File

@@ -1,4 +1,6 @@
import { z } from "zod";
import { splitSandboxBindSpec } from "../agents/sandbox/bind-spec.js";
import { isSandboxHostPathAbsolute } from "../agents/sandbox/host-paths.js";
import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import {
@@ -158,15 +160,16 @@ const SandboxDockerSchema = z
});
continue;
}
const firstColon = bind.indexOf(":");
const source = (firstColon <= 0 ? bind : bind.slice(0, firstColon)).trim();
if (!source.startsWith("/")) {
const parsed = splitSandboxBindSpec(bind);
const source = (parsed ? parsed.host : bind).trim();
if (!isSandboxHostPathAbsolute(source)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["binds", i],
message:
`Sandbox security: bind mount "${bind}" uses a non-absolute source path "${source}". ` +
"Only absolute POSIX paths are supported for sandbox binds.",
"Only absolute POSIX or Windows drive-letter paths are supported for sandbox binds.",
});
}
}

View File

@@ -126,6 +126,23 @@ describe("security audit sandbox docker config", () => {
},
],
},
{
name: "Windows drive-letter bind is absolute",
cfg: {
agents: {
defaults: {
sandbox: {
mode: "all",
docker: {
binds: ["D:/data/openclaw/src:/src:ro"],
},
},
},
},
} as OpenClawConfig,
expectedFindings: [],
expectedAbsent: ["sandbox.bind_mount_non_absolute"],
},
{
name: "container namespace join network mode",
cfg: {