Skip to content

Commit c04bbd3

Browse files
committed
fix(agents): allow dot-prefixed sandbox paths
1 parent fe89243 commit c04bbd3

9 files changed

Lines changed: 87 additions & 12 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev.
15+
- Agents: allow dot-dot-prefixed filenames such as `..note.txt` through sandbox FS bridge, remote sandbox reads, and apply_patch summaries without mistaking the name for parent traversal.
1516
- CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf.
1617
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (🔍) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
1718
- CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf.

src/agents/apply-patch.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,20 @@ describe("applyPatch", () => {
364364
});
365365
});
366366

367+
it("keeps dot-dot-prefixed filenames inside cwd and reports relative paths", async () => {
368+
await withTempDir(async (dir) => {
369+
const patch = `*** Begin Patch
370+
*** Add File: ..note.txt
371+
+inside
372+
*** End Patch`;
373+
374+
const result = await applyPatch(patch, { cwd: dir });
375+
376+
expect(result.summary.added).toEqual(["..note.txt"]);
377+
await expect(fs.readFile(path.join(dir, "..note.txt"), "utf8")).resolves.toBe("inside\n");
378+
});
379+
});
380+
367381
it("allows deleting a symlink itself even if it points outside cwd", async () => {
368382
await withTempDir(async (dir) => {
369383
const outsideDir = await fs.mkdtemp(path.join(path.dirname(dir), "openclaw-patch-outside-"));

src/agents/apply-patch.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ async function assertNoExistingParentAliases(params: { parentPath: string; rootP
326326
const rootPath = path.resolve(params.rootPath);
327327
const parentPath = path.resolve(params.parentPath);
328328
const relative = path.relative(rootPath, parentPath);
329-
if (!relative || relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
329+
if (!relative || relative === "" || relativePathEscapesRoot(relative)) {
330330
return;
331331
}
332332

@@ -410,12 +410,21 @@ function toDisplayPath(resolved: string, cwd: string): string {
410410
if (!relative || relative === "") {
411411
return path.basename(resolved);
412412
}
413-
if (relative.startsWith("..") || path.isAbsolute(relative)) {
413+
if (relativePathEscapesRoot(relative)) {
414414
return resolved;
415415
}
416416
return relative;
417417
}
418418

419+
function relativePathEscapesRoot(relativePath: string): boolean {
420+
return (
421+
relativePath === ".." ||
422+
relativePath.startsWith("../") ||
423+
relativePath.startsWith("..\\") ||
424+
path.isAbsolute(relativePath)
425+
);
426+
}
427+
419428
function parsePatchText(input: string): { hunks: Hunk[]; patch: string } {
420429
const trimmed = input.trim();
421430
if (!trimmed) {

src/agents/sandbox/fs-bridge-path-safety.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import path from "node:path";
33
import type { PathAliasPolicy } from "../../infra/path-alias-guards.js";
44
import { openRootFile, type RootFileOpenResult } from "./fs-bridge-path-safety.runtime.js";
55
import type { SandboxResolvedFsPath, SandboxFsMount } from "./fs-paths.js";
6-
import { isPathInsideContainerRoot, normalizeContainerPath } from "./path-utils.js";
6+
import {
7+
isPathInsideContainerRoot,
8+
normalizeContainerPath,
9+
relativePathEscapesContainerRoot,
10+
} from "./path-utils.js";
711

812
type BoundaryAllowedType = "file" | "directory";
913

@@ -96,7 +100,7 @@ export class SandboxFsPathGuard {
96100
action: string;
97101
}): PinnedSandboxEntry {
98102
const relativeParentPath = path.posix.relative(params.mount.containerRoot, params.parentPath);
99-
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
103+
if (relativePathEscapesContainerRoot(relativeParentPath)) {
100104
throw new Error(
101105
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.targetPath}`,
102106
);
@@ -217,7 +221,7 @@ export class SandboxFsPathGuard {
217221
): PinnedSandboxDirectoryEntry {
218222
const mount = this.resolveRequiredMount(target.containerPath, action);
219223
const relativePath = path.posix.relative(mount.containerRoot, target.containerPath);
220-
if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
224+
if (relativePathEscapesContainerRoot(relativePath)) {
221225
throw new Error(
222226
`Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
223227
);

src/agents/sandbox/fs-bridge.anchored-ops.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ describe("sandbox fs bridge anchored ops", () => {
133133
});
134134
});
135135

136+
it("allows dot-dot-prefixed sandbox entries without treating them as parent traversal", async () => {
137+
await withTempDir("openclaw-fs-bridge-dot-prefix-", async (stateDir) => {
138+
const { bridge } = await createSeededSandboxFsBridge(stateDir);
139+
140+
expect(bridge.resolvePath({ filePath: "..cache" })).toMatchObject({
141+
relativePath: "..cache",
142+
containerPath: "/workspace/..cache",
143+
});
144+
await bridge.mkdirp({ filePath: "..cache" });
145+
146+
const mkdirCall = requireDockerCall(findCallByDockerArg(1, "mkdirp"), "mkdirp");
147+
expect(getDockerArg(mkdirCall[0], 2)).toBe("/workspace");
148+
expect(getDockerArg(mkdirCall[0], 3)).toBe("..cache");
149+
});
150+
});
151+
136152
it.runIf(process.platform !== "win32")(
137153
"write resolves symlink parents to canonical pinned paths",
138154
async () => {

src/agents/sandbox/fs-paths.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import type { SandboxFsBridgeContext } from "./backend-handle.types.js";
77
import { splitSandboxBindSpec } from "./bind-spec.js";
88
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
99
import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js";
10-
import { isPathInsideContainerRoot, normalizeContainerPath } from "./path-utils.js";
10+
import {
11+
isPathInsideContainerRoot,
12+
normalizeContainerPath,
13+
relativePathEscapesContainerRoot,
14+
} from "./path-utils.js";
1115

1216
export type SandboxFsMount = {
1317
hostRoot: string;
@@ -271,7 +275,7 @@ function toDisplayRelative(params: {
271275
if (!rel) {
272276
return "";
273277
}
274-
if (!rel.startsWith("..") && !path.posix.isAbsolute(rel)) {
278+
if (!relativePathEscapesContainerRoot(rel)) {
275279
return rel;
276280
}
277281
return params.containerPath;

src/agents/sandbox/path-utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ export function isPathInsideContainerRoot(root: string, target: string): boolean
1313
}
1414
return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}/`);
1515
}
16+
17+
export function relativePathEscapesContainerRoot(relativePath: string): boolean {
18+
return (
19+
relativePath === ".." || relativePath.startsWith("../") || path.posix.isAbsolute(relativePath)
20+
);
21+
}

src/agents/sandbox/remote-fs-bridge.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,27 @@ describe("remote sandbox fs bridge", () => {
126126
},
127127
);
128128

129+
it.runIf(process.platform !== "win32")(
130+
"reads dot-dot-prefixed filenames inside the workspace",
131+
async () => {
132+
await withTempDir("openclaw-remote-fs-bridge-", async (stateDir) => {
133+
const workspaceDir = path.join(stateDir, "workspace");
134+
await fs.mkdir(workspaceDir, { recursive: true });
135+
await fs.writeFile(path.join(workspaceDir, "..note.txt"), "hidden", "utf8");
136+
137+
const bridge = createWorkspaceReadBridge(workspaceDir);
138+
139+
expect(bridge.resolvePath({ filePath: "..note.txt" })).toMatchObject({
140+
relativePath: "..note.txt",
141+
containerPath: `${workspaceDir}/..note.txt`,
142+
});
143+
await expect(bridge.readFile({ filePath: "..note.txt" })).resolves.toEqual(
144+
Buffer.from("hidden"),
145+
);
146+
});
147+
},
148+
);
149+
129150
it.runIf(process.platform !== "win32")("rejects symlink escapes while reading", async () => {
130151
await withTempDir("openclaw-remote-fs-bridge-", async (stateDir) => {
131152
const workspaceDir = path.join(stateDir, "workspace");

src/agents/sandbox/remote-fs-bridge.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-b
1111
import {
1212
isPathInsideContainerRoot,
1313
normalizeContainerPath as normalizeSandboxContainerPath,
14+
relativePathEscapesContainerRoot,
1415
} from "./path-utils.js";
1516

1617
type ResolvedRemotePath = SandboxResolvedPath & {
@@ -67,8 +68,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
6768
if (
6869
relativePath === "" ||
6970
relativePath === "." ||
70-
relativePath.startsWith("..") ||
71-
path.posix.isAbsolute(relativePath)
71+
relativePathEscapesContainerRoot(relativePath)
7272
) {
7373
throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
7474
}
@@ -124,7 +124,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
124124
const target = this.resolveTarget(params);
125125
this.ensureWritable(target, "create directories");
126126
const relativePath = path.posix.relative(target.mountRootPath, target.containerPath);
127-
if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
127+
if (relativePathEscapesContainerRoot(relativePath)) {
128128
throw new Error(
129129
`Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`,
130130
);
@@ -319,7 +319,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
319319

320320
private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath {
321321
const relative = path.posix.relative(params.mount.containerRoot, params.containerPath);
322-
if (relative.startsWith("..") || path.posix.isAbsolute(relative)) {
322+
if (relativePathEscapesContainerRoot(relative)) {
323323
throw new Error(
324324
`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`,
325325
);
@@ -460,7 +460,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
460460
);
461461
}
462462
const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent);
463-
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
463+
if (relativePathEscapesContainerRoot(relativeParentPath)) {
464464
throw new Error(
465465
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
466466
);

0 commit comments

Comments
 (0)