Skip to content

Commit 6def6e3

Browse files
committed
Share workspace roots with list_dir and delete_file bounds
listDirectory and deleteFilePlugin resolve through the shared workspace-root containment instead of cwd alone, so sibling worktree roots list and delete while genuinely outside paths stay refused. Session roots realpath before the lexical check, fixing aliased-tmp false denies. Wiring threads the worktree roots provider at both tool sites; yolo and allowOutside behavior is unchanged.
1 parent 8b85b1b commit 6def6e3

5 files changed

Lines changed: 55 additions & 51 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";
@@ -583,6 +584,7 @@ export async function createAgentToolset(
583584
}),
584585
createListDirTool(cwd, {
585586
allowOutside: () => permissionGate.getSkipPermissions(),
587+
rootsProvider: createWorktreeRootsProvider(cwd),
586588
}),
587589
createUseSkillTool(cwd, skillDirs, args.telemetry),
588590
createSkillSearchTool({ skills }),

src/list-dir.test.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { test, expect, describe } from "bun:test";
2-
import {
3-
mkdtemp,
4-
mkdir,
5-
realpath,
6-
symlink,
7-
writeFile,
8-
} from "node:fs/promises";
2+
import { mkdtemp, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
93
import { tmpdir } from "node:os";
104
import { basename, join } from "node:path";
115
import { listDirectory } from "./util/list-dir.js";

src/plugins/delete-file-plugin.ts

Lines changed: 23 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 { 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,24 @@ export function deleteFilePlugin(
8481
}
8582

8683
const allowOutside = resolveAllowOutside(options.allowOutside);
84+
// Containment is delegated to the shared workspace resolver, which
85+
// realpaths the session root before comparing and admits registered
86+
// sibling worktree roots — the same boundary pathEscapePlugin enforces.
87+
if (
88+
!allowOutside &&
89+
resolveWorkspacePath(
90+
cwd,
91+
args.path,
92+
options.rootsProvider ?? (() => []),
93+
) === undefined
94+
) {
95+
return errorResult(
96+
call.id,
97+
`${args.path} resolves outside the working directory`,
98+
);
99+
}
87100
const target = resolve(cwd, args.path);
88101
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-
}
99102
const info = await lstat(target);
100103
if (info.isDirectory()) {
101104
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)