From ae49b2137658464241dcac88ef35d06b0f77f142 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 11:27:24 -0400 Subject: [PATCH] fix(core): save external permissions at project root --- packages/core/src/location-mutation.ts | 9 ++++++- packages/core/src/project.ts | 11 +++++++++ packages/core/test/tool-write.test.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index c832477fbfb..37e7c85b6f6 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -5,6 +5,8 @@ import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" import { Location } from "./location" +import { Project } from "./project" +import { AbsolutePath } from "./schema" export const Kind = Schema.Literals(["file", "directory"]) export type Kind = typeof Kind.Type @@ -143,7 +145,12 @@ export const layer = Layer.effect( action: "external_directory", directory: externalDirectory, resource: externalResource, - save: externalResource, + save: slash( + path.join( + (yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, + "*", + ), + ), } : undefined, } satisfies Target diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 0ed2efa8668..f0705a9d36f 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -40,6 +40,17 @@ export interface Resolved { readonly vcs?: Vcs } +// Keep this filesystem-only; permission checks use it and should not execute VCS commands. +export const root = Effect.fn("Project.root")(function* ( + fs: FSUtil.Interface, + input: AbsolutePath, +) { + return yield* fs.up({ targets: [".git"], start: input }).pipe( + Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined), + Effect.catch(() => Effect.succeed(undefined)), + ) +}) + export interface Interface { readonly directories: (input: DirectoriesInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index a81b32d37d3..6a2b9d3f0f9 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -230,6 +230,40 @@ describe("WriteTool", () => { ), ) + it.live("saves external directory approval at the nearest project directory", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + const repo = path.join(outside.path, "repo") + const nested = path.join(repo, "packages", "app") + const target = path.join(nested, "external.txt") + return Effect.promise(() => + Promise.all([fs.mkdir(path.join(repo, ".git"), { recursive: true }), fs.mkdir(nested, { recursive: true })]), + ).pipe( + Effect.andThen( + withTool(active.path, (registry) => executeTool(registry, call({ path: target, content: "external" }))), + ), + Effect.andThen( + Effect.gen(function* () { + const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo)) + const canonicalNested = yield* Effect.promise(() => fs.realpath(nested)) + expect(assertions[0]).toMatchObject({ + action: "external_directory", + resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")], + save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")], + }) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + it.live("does not write when external_directory or edit approval is denied", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),