From 7111e71528165b262ebb170a8484318da819ff5e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:26:25 -0500 Subject: [PATCH] fix(core): treat project paths as internal (#45799) --- packages/core/src/location-mutation.ts | 13 ++++--- packages/core/test/location-mutation.test.ts | 36 +++++++++++++++++-- packages/core/test/tool-patch.test.ts | 7 ++-- packages/www/src/docs/content/permissions.mdx | 13 +++---- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 1475f9a3710..cc4e93781ab 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -16,7 +16,8 @@ export type Kind = typeof Kind.Type /** * Mutation paths do not accept project references. A leading `~` expands to * the home directory; other relative paths resolve from the active Location. - * Paths outside it require separate `external_directory` approval. + * Paths outside it and its non-root project worktree require separate + * `external_directory` approval. */ export const ResolveInput = Schema.Struct({ path: Schema.String, @@ -52,8 +53,8 @@ export interface Interface { /** * Resolve a path and derive its permission resources. A leading `~` expands * to the home directory; other relative paths resolve from the Location. - * Paths outside it require separate `external_directory` approval. This does - * not approve the mutation. + * Paths outside it and its non-root project worktree require separate + * `external_directory` approval. This does not approve the mutation. */ readonly resolve: (input: ResolveInput) => Effect.Effect } @@ -82,7 +83,11 @@ const layer = Layer.effect( const resolve = Effect.fnUntraced(function* (input: ResolveInput) { const absolute = resolvePath(location.directory, input.path) - if (FSUtil.contains(location.directory, absolute)) { + const worktree = path.resolve(location.project.directory) + const internal = + FSUtil.contains(location.directory, absolute) || + (worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute)) + if (internal) { return { absolute, resource: slash(path.relative(location.directory, absolute) || "."), diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index ee4da7d9dde..a768158a1ad 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -11,12 +11,20 @@ import { tmpdir } from "./fixture/tmpdir" import { location } from "./fixture/location" import { it } from "./lib/effect" -function provide(directory: string) { +function provide(directory: string, projectDirectory = directory) { return Effect.provide( LayerNode.compile(LocationMutation.node, [ [ Location.node, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory) }, + ), + ), + ), ], ]), ) @@ -76,6 +84,30 @@ describe("LocationMutation", () => { ), ) + it.live("allows a relative path outside the Location but inside the project worktree", () => + withTmp((directory) => + Effect.gen(function* () { + const active = path.join(directory, "packages", "opencode") + yield* Effect.promise(() => fs.mkdir(active, { recursive: true })) + const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" }) + expect(target).toMatchObject({ + absolute: path.join(directory, "README.md"), + resource: "../../README.md", + }) + expect(target.externalDirectory).toBeUndefined() + }).pipe(provide(path.join(directory, "packages", "opencode"), directory)), + ), + ) + + it.live("does not treat a filesystem-root project sentinel as an internal boundary", () => + withTmp((directory) => + Effect.gen(function* () { + const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" }) + expect(target.externalDirectory).toBeDefined() + }).pipe(provide(directory, path.parse(directory).root)), + ), + ) + it.live("resolves a prospective target below an external symlink lexically", () => withTmp((directory) => { const outside = `${directory}-outside` diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 1f8d13b511a..0295b042009 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -920,7 +920,7 @@ describe("PatchTool", () => { ), ) - it.live("treats a sibling path inside the project worktree as external to the Location", () => + it.live("treats a sibling path inside the project worktree as internal", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { @@ -939,9 +939,8 @@ describe("PatchTool", () => { call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), ), ).toMatchObject({ status: "completed" }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")]) - expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")]) + expect(assertions.map((input) => input.action)).toEqual(["edit"]) + expect(assertions[0]?.resources).toEqual(["../sibling.txt"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), tmp.path, diff --git a/packages/www/src/docs/content/permissions.mdx b/packages/www/src/docs/content/permissions.mdx index a97921c1f2e..1b6c66d1056 100644 --- a/packages/www/src/docs/content/permissions.mdx +++ b/packages/www/src/docs/content/permissions.mdx @@ -83,10 +83,10 @@ current built-in actions use these resources: ## External directories -An external path requires a separate `external_directory` decision before the -tool's own `read` or `edit` decision. This applies to external paths used by -`read`, `edit`, `write`, and `patch`, and to an external `shell` working -directory. +A path outside both the active Location and its non-root project worktree +requires a separate `external_directory` decision before the tool's own `read` +or `edit` decision. This applies to external paths used by `read`, `edit`, +`write`, and `patch`, and to an external `shell` working directory. ```jsonc { @@ -122,8 +122,9 @@ raw command text and are **not** home-expanded. identify every dangerous command. -Relative mutation paths cannot escape the active Location, and symlink escapes -from inside it are rejected. Explicit external paths are canonicalized before +Relative mutation paths may traverse outside the active Location while +remaining inside its project worktree. Paths outside both boundaries require +`external_directory` approval. Explicit external paths are canonicalized before matching, so authorize only trusted directory boundaries. ## Experimental shell scanner