Skip to content

Commit d69c64c

Browse files
Merge pull request #1009 from corbitsdev/cl-6729-share-workspace-roots-with-list_dir-and-delete_file-bounds
Share workspace roots with list_dir and delete_file bounds
2 parents ad410ef + 02a9ec7 commit d69c64c

6 files changed

Lines changed: 185 additions & 46 deletions

File tree

src/agent/posix-tool-plugins.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ export function buildCorePosixToolPlugins(
9999
// rebuilding the plugin stack. Secret-guard and authz still hard-deny
100100
// regardless.
101101
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
102+
// One shared workspace-roots provider for every bound in this stack, so
103+
// pathEscape and delete_file admit the same registered sibling worktrees.
104+
const rootsProvider = createWorktreeRootsProvider(cwd);
102105
const truncationOptions =
103106
getBlobWriter !== undefined ||
104107
getContextDir !== undefined ||
@@ -112,9 +115,9 @@ export function buildCorePosixToolPlugins(
112115
return [
113116
resultTruncationPlugin(truncationOptions),
114117
toolResultSecretScrubPlugin(),
115-
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }),
118+
pathEscapePlugin(cwd, rootsProvider, { allowOutside }),
116119
evidenceArchivePathGuardPlugin(),
117-
deleteFilePlugin(cwd, { allowOutside }),
120+
deleteFilePlugin(cwd, { allowOutside, rootsProvider }),
118121
toolOutputUriPlugin(),
119122
secretGuardPlugin(),
120123
authzPlugin(),

src/agent/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js";
1919
import { advertiseArchiveSurface } from "../plugins/evidence-archive-search-plugin.js";
2020
import type { Telemetry } from "../telemetry/index.js";
2121
import type { PermissionGate } from "../permission/gate.js";
22+
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
2223
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
2324
import { createLazyBlobReader } from "./lazy-blob-reader.js";
2425
import type { BlobReader } from "@intx/types/runtime";
@@ -591,6 +592,7 @@ export async function createAgentToolset(
591592
}),
592593
createListDirTool(cwd, {
593594
allowOutside: () => permissionGate.getSkipPermissions(),
595+
rootsProvider: createWorktreeRootsProvider(cwd),
594596
}),
595597
createUseSkillTool(cwd, skillDirs, args.telemetry),
596598
createSkillSearchTool({ skills }),

src/list-dir.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test, expect, describe } from "bun:test";
2-
import { mkdtemp, mkdir, writeFile, symlink } from "node:fs/promises";
2+
import { mkdtemp, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
4+
import { basename, join } from "node:path";
55
import { listDirectory } from "./util/list-dir.js";
66

77
async function fixture(): Promise<string> {
@@ -81,4 +81,40 @@ describe("listDirectory", () => {
8181
expect(out.split("\n")).toContain("other.txt");
8282
expect(out).not.toContain("outside the workspace");
8383
});
84+
85+
test("lists a registered sibling worktree root (CL-6729)", async () => {
86+
const dir = await fixture();
87+
const sibling = await mkdtemp(join(tmpdir(), "list-dir-sibling-"));
88+
await writeFile(join(sibling, "sibling-file.txt"), "");
89+
const roots = [await realpath(sibling)];
90+
91+
const out = await listDirectory(dir, sibling, {
92+
rootsProvider: () => roots,
93+
});
94+
expect(out.split("\n")).toContain("sibling-file.txt");
95+
expect(out).not.toContain("outside the workspace");
96+
});
97+
98+
test("lists a sibling worktree via relative traversal (CL-6729)", async () => {
99+
const dir = await fixture();
100+
const sibling = await mkdtemp(join(tmpdir(), "list-dir-sibling-rel-"));
101+
await writeFile(join(sibling, "sibling-file.txt"), "");
102+
const roots = [await realpath(sibling)];
103+
104+
const out = await listDirectory(dir, join("..", basename(sibling)), {
105+
rootsProvider: () => roots,
106+
});
107+
expect(out.split("\n")).toContain("sibling-file.txt");
108+
expect(out).not.toContain("outside the workspace");
109+
});
110+
111+
test("lists through an aliased session root (CL-6729)", async () => {
112+
const dir = await fixture();
113+
const realDir = await realpath(dir);
114+
const alias = `${realDir}-alias`;
115+
await symlink(realDir, alias);
116+
117+
const out = await listDirectory(alias, realDir);
118+
expect(out.split("\n")).toEqual(["a.ts", "b.ts", "sub/"]);
119+
});
84120
});

src/plugins/delete-file-plugin.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
import {
33
chmod,
4+
lstat,
45
mkdtemp,
56
mkdir,
7+
readFile,
8+
realpath,
69
rm,
710
stat,
811
symlink,
@@ -30,6 +33,15 @@ async function exists(path: string): Promise<boolean> {
3033
}
3134
}
3235

36+
async function linkExists(path: string): Promise<boolean> {
37+
try {
38+
await lstat(path);
39+
return true;
40+
} catch {
41+
return false;
42+
}
43+
}
44+
3345
describe("deleteFilePlugin", () => {
3446
let cwd: string;
3547

@@ -129,6 +141,43 @@ describe("deleteFilePlugin", () => {
129141
await rm(outside, { recursive: true, force: true });
130142
});
131143

144+
test("deletes a dangling symlink inside cwd (CL-6729)", async () => {
145+
const link = join(cwd, "broken-link");
146+
await symlink(join(cwd, "does-not-exist.txt"), link);
147+
expect(await linkExists(link)).toBe(true);
148+
149+
const result = await handler()(
150+
call("broken-link"),
151+
new AbortController().signal,
152+
);
153+
154+
expect(result.isError ?? false).toBe(false);
155+
expect(String(result.content)).toContain("Deleted file: broken-link");
156+
expect(await linkExists(link)).toBe(false);
157+
});
158+
159+
test("deletes a link with an outside referent without touching the referent (CL-6729)", async () => {
160+
const outside = await mkdtemp(
161+
join(tmpdir(), "corbits-delete-link-referent-"),
162+
);
163+
const referent = join(outside, "keep.txt");
164+
await writeFile(referent, "keep");
165+
const link = join(cwd, "outside-link");
166+
await symlink(referent, link);
167+
expect(await linkExists(link)).toBe(true);
168+
169+
const result = await handler()(
170+
call("outside-link"),
171+
new AbortController().signal,
172+
);
173+
174+
expect(result.isError ?? false).toBe(false);
175+
expect(String(result.content)).toContain("Deleted file: outside-link");
176+
expect(await linkExists(link)).toBe(false);
177+
expect(await readFile(referent, "utf8")).toBe("keep");
178+
await rm(outside, { recursive: true, force: true });
179+
});
180+
132181
test("allowOutside deletes a file outside the working directory", async () => {
133182
const outside = await mkdtemp(join(tmpdir(), "corbits-delete-yolo-"));
134183
const path = join(outside, "gone.txt");
@@ -201,6 +250,46 @@ describe("deleteFilePlugin", () => {
201250
expect(await exists(path)).toBe(true);
202251
});
203252

253+
test("deletes a file in a registered sibling worktree (CL-6729)", async () => {
254+
const sibling = await mkdtemp(join(tmpdir(), "corbits-delete-sibling-"));
255+
const path = join(sibling, "old.txt");
256+
await writeFile(path, "old");
257+
const roots = [await realpath(sibling)];
258+
const tool = deleteFilePlugin(cwd, { rootsProvider: () => roots })
259+
.tools?.[0];
260+
if (tool === undefined)
261+
throw new Error("delete_file tool was not registered");
262+
263+
const result = await tool.handler(call(path), new AbortController().signal);
264+
265+
expect(result.isError ?? false).toBe(false);
266+
expect(String(result.content)).toContain("Deleted file");
267+
expect(await exists(path)).toBe(false);
268+
await rm(sibling, { recursive: true, force: true });
269+
});
270+
271+
test("still refuses a genuinely outside file when roots are registered (CL-6729)", async () => {
272+
const sibling = await mkdtemp(
273+
join(tmpdir(), "corbits-delete-sibling-keep-"),
274+
);
275+
const outside = await mkdtemp(join(tmpdir(), "corbits-delete-outside-"));
276+
const path = join(outside, "keep.txt");
277+
await writeFile(path, "keep");
278+
const roots = [await realpath(sibling)];
279+
const tool = deleteFilePlugin(cwd, { rootsProvider: () => roots })
280+
.tools?.[0];
281+
if (tool === undefined)
282+
throw new Error("delete_file tool was not registered");
283+
284+
const result = await tool.handler(call(path), new AbortController().signal);
285+
286+
expect(result.isError).toBe(true);
287+
expect(String(result.content)).toContain("outside the working directory");
288+
expect(await exists(path)).toBe(true);
289+
await rm(sibling, { recursive: true, force: true });
290+
await rm(outside, { recursive: true, force: true });
291+
});
292+
204293
test("preserves filesystem failure details", async () => {
205294
const path = join(cwd, "locked.txt");
206295
await writeFile(path, "keep");

src/plugins/delete-file-plugin.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { lstat, readFile, realpath, unlink } from "node:fs/promises";
2-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
1+
import { lstat, readFile, unlink } from "node:fs/promises";
2+
import { dirname, resolve } from "node:path";
33
import { type } from "arktype";
44
import type { ExtraTool, ToolPlugin } from "@intx/tools-posix";
55
import type { ToolCall, ToolResult } from "@intx/types/runtime";
6+
import { resolveWorkspacePath } from "../permission/path-restriction.js";
7+
import type { RootsProvider } from "../permission/worktree-roots.js";
68
import { formatChangeDiff } from "./change-diff.js";
79

810
const DeleteFileArgs = type({ path: "string>0" });
@@ -43,19 +45,14 @@ function failureDetail(error: unknown): string {
4345
return code === undefined ? error.message : `${code}: ${error.message}`;
4446
}
4547

46-
function isWithin(root: string, path: string): boolean {
47-
const rel = relative(root, path);
48-
return (
49-
rel === "" ||
50-
(rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
51-
);
52-
}
53-
5448
export interface DeleteFilePluginOptions {
5549
// When true (yolo / --dangerously-skip-permissions), delete outside the
5650
// working directory. A getter is resolved per call so `/yolo` mid-session
5751
// takes effect without rebuilding the plugin stack.
5852
allowOutside?: boolean | (() => boolean);
53+
// Workspace roots beyond cwd (the session's registered git worktrees).
54+
// Defaults to cwd alone.
55+
rootsProvider?: RootsProvider;
5956
}
6057

6158
function resolveAllowOutside(
@@ -84,18 +81,28 @@ export function deleteFilePlugin(
8481
}
8582

8683
const allowOutside = resolveAllowOutside(options.allowOutside);
84+
// Containment is keyed off the parent directory, not the full target:
85+
// lstat/unlink never follow the final component, so unlinking a link
86+
// itself cannot escape even when the link dangles or points outside.
87+
// Resolving the full target would refuse both (UNRESOLVABLE / outside
88+
// referent). The shared workspace resolver still realpaths the session
89+
// root before comparing and admits registered sibling worktree roots —
90+
// the same boundary pathEscapePlugin enforces.
8791
const target = resolve(cwd, args.path);
92+
if (
93+
!allowOutside &&
94+
resolveWorkspacePath(
95+
cwd,
96+
dirname(target),
97+
options.rootsProvider ?? (() => []),
98+
) === undefined
99+
) {
100+
return errorResult(
101+
call.id,
102+
`${args.path} resolves outside the working directory`,
103+
);
104+
}
88105
try {
89-
const [physicalRoot, physicalParent] = await Promise.all([
90-
realpath(cwd),
91-
realpath(dirname(target)),
92-
]);
93-
if (!allowOutside && !isWithin(physicalRoot, physicalParent)) {
94-
return errorResult(
95-
call.id,
96-
`${args.path} resolves outside the working directory`,
97-
);
98-
}
99106
const info = await lstat(target);
100107
if (info.isDirectory()) {
101108
return errorResult(

src/util/list-dir.ts

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { type } from "arktype";
22
import { readdir, realpath } from "node:fs/promises";
3-
import { resolve, sep } from "node:path";
3+
import { resolve } from "node:path";
44
import { stringTool } from "@intx/agent";
55
import type { AgentTool } from "@intx/agent";
66
import type { ToolDefinition } from "@intx/types/runtime";
7+
import { resolveWorkspacePath } from "../permission/path-restriction.js";
8+
import type { RootsProvider } from "../permission/worktree-roots.js";
79

810
const ListDirArgs = type({ "path?": "string" });
911

@@ -31,6 +33,9 @@ export interface ListDirectoryOptions {
3133
// workspace. A getter is resolved per call so `/yolo` mid-session takes
3234
// effect without rebuilding the tool.
3335
allowOutside?: boolean | (() => boolean);
36+
// Workspace roots beyond cwd (the session's registered git worktrees).
37+
// Defaults to cwd alone.
38+
rootsProvider?: RootsProvider;
3439
}
3540

3641
function resolveAllowOutside(
@@ -47,29 +52,26 @@ export async function listDirectory(
4752
): Promise<string> {
4853
const allowOutside = resolveAllowOutside(options.allowOutside);
4954
const rel = path.length > 0 ? path : ".";
50-
const abs = resolve(cwd, rel);
51-
if (!allowOutside && abs !== cwd && !abs.startsWith(cwd + sep)) {
52-
return `Error: ${rel} is outside the workspace.`;
53-
}
55+
const rootsProvider = options.rootsProvider ?? (() => []);
5456

55-
// A symlink inside the workspace can resolve to a target outside it; the
56-
// string prefix check above only sees the lexical path. Resolve the real path
57-
// of both the target and the root before comparing so symlink escapes are
58-
// refused (unless allowOutside, which is the yolo-mode escape hatch).
57+
// Containment is delegated to the shared workspace resolver: it realpaths
58+
// the session root before comparing (so an aliased cwd such as macOS
59+
// /tmp -> /private/tmp never false-denies) and admits registered sibling
60+
// worktree roots. The canonical path feeds readdir directly, so a symlink
61+
// retargeted after the check cannot redirect the read.
5962
let realAbs: string;
60-
let realCwd: string;
61-
try {
62-
realAbs = await realpath(abs);
63-
realCwd = await realpath(cwd);
64-
} catch (err) {
65-
return `Error: cannot list ${rel}: ${err instanceof Error ? err.message : String(err)}`;
66-
}
67-
if (
68-
!allowOutside &&
69-
realAbs !== realCwd &&
70-
!realAbs.startsWith(realCwd + sep)
71-
) {
72-
return `Error: ${rel} is outside the workspace.`;
63+
if (allowOutside) {
64+
try {
65+
realAbs = await realpath(resolve(cwd, rel));
66+
} catch (err) {
67+
return `Error: cannot list ${rel}: ${err instanceof Error ? err.message : String(err)}`;
68+
}
69+
} else {
70+
const resolved = resolveWorkspacePath(cwd, rel, rootsProvider);
71+
if (resolved === undefined) {
72+
return `Error: ${rel} is outside the workspace.`;
73+
}
74+
realAbs = resolved;
7375
}
7476

7577
let entries;

0 commit comments

Comments
 (0)