fix(core): treat project paths as internal (#45799)

This commit is contained in:
Aiden Cline 2026-08-27 20:26:25 -05:00 committed by GitHub
parent 0ce3214844
commit 7111e71528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 53 additions and 16 deletions

View file

@ -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<Target, FSUtil.Error>
}
@ -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) || "."),

View file

@ -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`

View file

@ -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,

View file

@ -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.
</Callout>
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