Skip to content

Commit d02fbc6

Browse files
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>
1 parent 3526687 commit d02fbc6

9 files changed

Lines changed: 167 additions & 25 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
6161
- 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.
6262
- 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.
6363
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
64+
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.
6465

6566
### Fixes
6667

extensions/whatsapp/src/setup-finalize.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import path from "node:path";
22
import {
33
DEFAULT_ACCOUNT_ID,
4-
normalizeE164,
54
pathExists,
65
splitSetupEntries,
76
type DmPolicy,

src/agents/sandbox/host-paths.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { describe, expect, it } from "vitest";
55
import {
6+
getSandboxHostPathPolicyKey,
7+
isSandboxHostPathAbsolute,
68
normalizeSandboxHostPath,
79
resolveSandboxHostPathViaExistingAncestor,
810
} from "./host-paths.js";
@@ -11,13 +13,50 @@ describe("normalizeSandboxHostPath", () => {
1113
it("normalizes dot segments and strips trailing slash", () => {
1214
expect(normalizeSandboxHostPath("/tmp/a/../b//")).toBe("/tmp/b");
1315
});
16+
17+
it("normalizes Windows drive-letter paths without losing the drive root", () => {
18+
expect(normalizeSandboxHostPath("c:\\Users\\Kai\\..\\Project\\")).toBe("C:/Users/Project");
19+
expect(normalizeSandboxHostPath("d:/")).toBe("D:/");
20+
});
21+
});
22+
23+
describe("isSandboxHostPathAbsolute", () => {
24+
it("accepts POSIX and drive-absolute Windows paths", () => {
25+
expect(isSandboxHostPathAbsolute("/tmp/project")).toBe(true);
26+
expect(isSandboxHostPathAbsolute("C:/Users/kai/project")).toBe(true);
27+
expect(isSandboxHostPathAbsolute("C:\\Users\\kai\\project")).toBe(true);
28+
});
29+
30+
it("rejects relative paths, named volumes, and drive-relative Windows paths", () => {
31+
expect(isSandboxHostPathAbsolute("relative/path")).toBe(false);
32+
expect(isSandboxHostPathAbsolute("my-volume")).toBe(false);
33+
expect(isSandboxHostPathAbsolute("C:relative\\path")).toBe(false);
34+
});
35+
});
36+
37+
describe("getSandboxHostPathPolicyKey", () => {
38+
it("compares Windows drive-letter paths case-insensitively", () => {
39+
expect(getSandboxHostPathPolicyKey("c:\\Users\\Kai\\.SSH\\config")).toBe(
40+
"c:/users/kai/.ssh/config",
41+
);
42+
});
1443
});
1544

1645
describe("resolveSandboxHostPathViaExistingAncestor", () => {
1746
it("keeps non-absolute paths unchanged", () => {
1847
expect(resolveSandboxHostPathViaExistingAncestor("relative/path")).toBe("relative/path");
1948
});
2049

50+
it("normalizes Windows paths without resolving them through POSIX cwd on non-Windows hosts", () => {
51+
if (process.platform === "win32") {
52+
return;
53+
}
54+
55+
expect(resolveSandboxHostPathViaExistingAncestor("C:/Users/kai/project")).toBe(
56+
"C:/Users/kai/project",
57+
);
58+
});
59+
2160
it("resolves symlink parents when the final leaf does not exist", () => {
2261
if (process.platform === "win32") {
2362
return;

src/agents/sandbox/host-paths.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,54 @@ function stripWindowsNamespacePrefix(input: string): string {
1919
return input;
2020
}
2121

22+
export function isWindowsDriveAbsolutePath(raw: string): boolean {
23+
return /^[A-Za-z]:[\\/]/.test(stripWindowsNamespacePrefix(raw.trim()));
24+
}
25+
26+
export function isSandboxHostPathAbsolute(raw: string): boolean {
27+
const trimmed = stripWindowsNamespacePrefix(raw.trim());
28+
return trimmed.startsWith("/") || isWindowsDriveAbsolutePath(trimmed);
29+
}
30+
2231
/**
23-
* Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
32+
* Normalize a host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
33+
* Windows drive-letter paths preserve the drive root and uppercase the drive letter.
2434
*/
2535
export function normalizeSandboxHostPath(raw: string): string {
2636
const trimmed = stripWindowsNamespacePrefix(raw.trim());
2737
if (!trimmed) {
2838
return "/";
2939
}
30-
const normalized = posix.normalize(trimmed.replaceAll("\\", "/"));
31-
return normalized.replace(/\/+$/, "") || "/";
40+
let normalTrimmed = trimmed.replaceAll("\\", "/");
41+
if (isWindowsDriveAbsolutePath(normalTrimmed)) {
42+
normalTrimmed = normalTrimmed.charAt(0).toUpperCase() + normalTrimmed.slice(1);
43+
}
44+
const normalized = posix.normalize(normalTrimmed);
45+
const withoutTrailingSlash = normalized.replace(/\/+$/, "") || "/";
46+
if (/^[A-Z]:$/.test(withoutTrailingSlash)) {
47+
return `${withoutTrailingSlash}/`;
48+
}
49+
return withoutTrailingSlash;
50+
}
51+
52+
export function getSandboxHostPathPolicyKey(raw: string): string {
53+
const normalized = normalizeSandboxHostPath(raw);
54+
if (isWindowsDriveAbsolutePath(normalized)) {
55+
return normalized.toLowerCase();
56+
}
57+
return normalized;
3258
}
3359

3460
/**
3561
* Resolve a path through the deepest existing ancestor so parent symlinks are honored
3662
* even when the final source leaf does not exist yet.
3763
*/
3864
export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): string {
39-
if (!sourcePath.startsWith("/")) {
65+
if (!isSandboxHostPathAbsolute(sourcePath)) {
4066
return sourcePath;
4167
}
68+
if (isWindowsDriveAbsolutePath(sourcePath) && process.platform !== "win32") {
69+
return normalizeSandboxHostPath(sourcePath);
70+
}
4271
return normalizeSandboxHostPath(resolvePathViaExistingAncestorSync(sourcePath));
4372
}

src/agents/sandbox/validate-sandbox-security.test.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,25 @@ describe("validateBindMounts", () => {
174174
expect(() => validateBindMounts(["/home/tester/.netrc:/mnt/netrc:ro"])).toThrow(/blocked path/);
175175
});
176176

177+
it("allows drive-absolute Windows bind sources", () => {
178+
expect(() => validateBindMounts(["D:/data/openclaw/src:/src:ro"])).not.toThrow();
179+
expect(() => validateBindMounts(["D:\\data\\openclaw\\output:/output:rw"])).not.toThrow();
180+
});
181+
182+
it("compares Windows allowed roots case-insensitively", () => {
183+
expect(() =>
184+
validateBindMounts(["d:/DATA/OpenClaw/src:/src:ro"], {
185+
allowedSourceRoots: ["D:/data/openclaw"],
186+
}),
187+
).not.toThrow();
188+
189+
expect(() =>
190+
validateBindMounts(["D:/other/project:/src:ro"], {
191+
allowedSourceRoots: ["d:/data/openclaw"],
192+
}),
193+
).toThrow(/outside allowed roots/);
194+
});
195+
177196
it("blocks credential binds through canonical home aliases", () => {
178197
if (process.platform === "win32") {
179198
return;
@@ -193,14 +212,7 @@ describe("validateBindMounts", () => {
193212

194213
it("blocks symlink escapes into blocked directories", () => {
195214
if (process.platform === "win32") {
196-
// Symlinks to non-existent targets like /etc require
197-
// SeCreateSymbolicLinkPrivilege on Windows. The Windows branch of this
198-
// test does not need a real symlink — it only asserts that Windows source
199-
// paths are rejected as non-POSIX.
200-
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
201-
const fakePath = join(dir, "etc-link", "passwd");
202-
const run = () => validateBindMounts([`${fakePath}:/mnt/passwd:ro`]);
203-
expect(run).toThrow(/non-absolute source path/);
215+
// Symlink setup for blocked POSIX targets like /etc is POSIX-only.
204216
return;
205217
}
206218

@@ -213,7 +225,7 @@ describe("validateBindMounts", () => {
213225

214226
it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => {
215227
if (process.platform === "win32") {
216-
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
228+
// Windows symlink semantics differ; POSIX symlink escape coverage runs on POSIX hosts.
217229
return;
218230
}
219231
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
@@ -233,7 +245,7 @@ describe("validateBindMounts", () => {
233245

234246
it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => {
235247
if (process.platform === "win32") {
236-
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
248+
// Symlink setup for blocked POSIX targets like /var/run is POSIX-only.
237249
return;
238250
}
239251
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));

src/agents/sandbox/validate-sandbox-security.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"
1212
import { splitSandboxBindSpec } from "./bind-spec.js";
1313
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
1414
import {
15+
getSandboxHostPathPolicyKey,
16+
isSandboxHostPathAbsolute,
1517
normalizeSandboxHostPath,
1618
resolveSandboxHostPathViaExistingAncestor,
1719
} from "./host-paths.js";
@@ -101,6 +103,7 @@ function parseBindTargetPath(bind: string): string {
101103

102104
/**
103105
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
106+
* If it starts with the drive letter, convert it to the upper case.
104107
*/
105108
function normalizeHostPath(raw: string): string {
106109
return normalizeSandboxHostPath(raw);
@@ -115,10 +118,9 @@ function normalizeHostPath(raw: string): string {
115118
*/
116119
export function getBlockedBindReason(bind: string): BlockedBindReason | null {
117120
const sourceRaw = parseBindSourcePath(bind);
118-
if (!sourceRaw.startsWith("/")) {
121+
if (!isSandboxHostPathAbsolute(sourceRaw)) {
119122
return { kind: "non_absolute", sourcePath: sourceRaw };
120123
}
121-
122124
const normalized = normalizeHostPath(sourceRaw);
123125
const blockedHostPaths = getBlockedHostPaths();
124126
const directReason = getBlockedReasonForSourcePath(normalized, blockedHostPaths);
@@ -141,8 +143,10 @@ function getBlockedReasonForSourcePath(
141143
if (sourceNormalized === "/") {
142144
return { kind: "covers", blockedPath: "/" };
143145
}
146+
const sourceKey = getSandboxHostPathPolicyKey(sourceNormalized);
144147
for (const blocked of blockedHostPaths) {
145-
if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) {
148+
const blockedKey = getSandboxHostPathPolicyKey(blocked);
149+
if (sourceKey === blockedKey || sourceKey.startsWith(`${blockedKey}/`)) {
146150
return { kind: "targets", blockedPath: blocked };
147151
}
148152
}
@@ -193,7 +197,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] {
193197
}
194198
const normalized = roots
195199
.map((entry) => entry.trim())
196-
.filter((entry) => entry.startsWith("/"))
200+
.filter(isSandboxHostPathAbsolute)
197201
.map(normalizeHostPath);
198202
const expanded = new Set<string>();
199203
for (const root of normalized) {
@@ -210,7 +214,9 @@ function isPathInsidePosix(root: string, target: string): boolean {
210214
if (root === "/") {
211215
return true;
212216
}
213-
return target === root || target.startsWith(`${root}/`);
217+
const rootKey = getSandboxHostPathPolicyKey(root);
218+
const targetKey = getSandboxHostPathPolicyKey(target);
219+
return targetKey === rootKey || targetKey.startsWith(`${rootKey}/`);
214220
}
215221

216222
function getOutsideAllowedRootsReason(
@@ -274,7 +280,7 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso
274280
if (params.reason.kind === "non_absolute") {
275281
return new Error(
276282
`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` +
277-
`"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`,
283+
`"${params.reason.sourcePath}". Only absolute POSIX or Windows drive-letter paths are supported for sandbox binds.`,
278284
);
279285
}
280286
if (params.reason.kind === "outside_allowed_roots") {

src/config/config.sandbox-docker.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,42 @@ describe("sandbox docker config", () => {
6262
}
6363
});
6464

65+
it("accepts Windows drive-letter binds in sandbox.docker config", () => {
66+
const res = validateConfigObject({
67+
agents: {
68+
defaults: {
69+
sandbox: {
70+
docker: {
71+
binds: ["D:/data/openclaw/src:/src:ro", "D:\\data\\openclaw\\output:/output:rw"],
72+
},
73+
},
74+
},
75+
},
76+
});
77+
expect(res.ok).toBe(true);
78+
if (res.ok) {
79+
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
80+
"D:/data/openclaw/src:/src:ro",
81+
"D:\\data\\openclaw\\output:/output:rw",
82+
]);
83+
}
84+
});
85+
86+
it("rejects drive-relative Windows binds in sandbox.docker config", () => {
87+
const res = validateConfigObject({
88+
agents: {
89+
defaults: {
90+
sandbox: {
91+
docker: {
92+
binds: ["D:relative\\path:/src:ro"],
93+
},
94+
},
95+
},
96+
},
97+
});
98+
expect(res.ok).toBe(false);
99+
});
100+
65101
it("accepts non-empty Docker GPU passthrough config", () => {
66102
const res = validateConfigObject({
67103
agents: {

src/config/zod-schema.agent-runtime.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { z } from "zod";
2+
import { splitSandboxBindSpec } from "../agents/sandbox/bind-spec.js";
3+
import { isSandboxHostPathAbsolute } from "../agents/sandbox/host-paths.js";
24
import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js";
35
import { parseDurationMs } from "../cli/parse-duration.js";
46
import {
@@ -158,15 +160,16 @@ const SandboxDockerSchema = z
158160
});
159161
continue;
160162
}
161-
const firstColon = bind.indexOf(":");
162-
const source = (firstColon <= 0 ? bind : bind.slice(0, firstColon)).trim();
163-
if (!source.startsWith("/")) {
163+
164+
const parsed = splitSandboxBindSpec(bind);
165+
const source = (parsed ? parsed.host : bind).trim();
166+
if (!isSandboxHostPathAbsolute(source)) {
164167
ctx.addIssue({
165168
code: z.ZodIssueCode.custom,
166169
path: ["binds", i],
167170
message:
168171
`Sandbox security: bind mount "${bind}" uses a non-absolute source path "${source}". ` +
169-
"Only absolute POSIX paths are supported for sandbox binds.",
172+
"Only absolute POSIX or Windows drive-letter paths are supported for sandbox binds.",
170173
});
171174
}
172175
}

src/security/audit-sandbox-docker-config.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ describe("security audit sandbox docker config", () => {
126126
},
127127
],
128128
},
129+
{
130+
name: "Windows drive-letter bind is absolute",
131+
cfg: {
132+
agents: {
133+
defaults: {
134+
sandbox: {
135+
mode: "all",
136+
docker: {
137+
binds: ["D:/data/openclaw/src:/src:ro"],
138+
},
139+
},
140+
},
141+
},
142+
} as OpenClawConfig,
143+
expectedFindings: [],
144+
expectedAbsent: ["sandbox.bind_mount_non_absolute"],
145+
},
129146
{
130147
name: "container namespace join network mode",
131148
cfg: {

0 commit comments

Comments
 (0)