|
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"; |
3 | 3 | import { type } from "arktype"; |
4 | 4 | import type { ExtraTool, ToolPlugin } from "@intx/tools-posix"; |
5 | 5 | 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"; |
6 | 8 | import { formatChangeDiff } from "./change-diff.js"; |
7 | 9 |
|
8 | 10 | const DeleteFileArgs = type({ path: "string>0" }); |
@@ -43,19 +45,14 @@ function failureDetail(error: unknown): string { |
43 | 45 | return code === undefined ? error.message : `${code}: ${error.message}`; |
44 | 46 | } |
45 | 47 |
|
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 | | - |
54 | 48 | export interface DeleteFilePluginOptions { |
55 | 49 | // When true (yolo / --dangerously-skip-permissions), delete outside the |
56 | 50 | // working directory. A getter is resolved per call so `/yolo` mid-session |
57 | 51 | // takes effect without rebuilding the plugin stack. |
58 | 52 | allowOutside?: boolean | (() => boolean); |
| 53 | + // Workspace roots beyond cwd (the session's registered git worktrees). |
| 54 | + // Defaults to cwd alone. |
| 55 | + rootsProvider?: RootsProvider; |
59 | 56 | } |
60 | 57 |
|
61 | 58 | function resolveAllowOutside( |
@@ -84,18 +81,24 @@ export function deleteFilePlugin( |
84 | 81 | } |
85 | 82 |
|
86 | 83 | 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 | + } |
87 | 100 | const target = resolve(cwd, args.path); |
88 | 101 | 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 | | - } |
99 | 102 | const info = await lstat(target); |
100 | 103 | if (info.isDirectory()) { |
101 | 104 | return errorResult( |
|
0 commit comments