fix(core): authorize external read paths

This commit is contained in:
Dax Raad 2026-06-26 13:55:04 -04:00
commit 1ac6b4bec4
2 changed files with 80 additions and 22 deletions

View file

@ -11,6 +11,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
@ -97,6 +98,32 @@ const infrastructure = Layer.mergeAll(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
)
const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
if (input.path === missingPath)
return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
const canonical = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
const directory = path.dirname(canonical)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
canonical,
resource,
externalDirectory: external
? {
action: "external_directory" as const,
directory,
resource: externalResource,
save: externalResource,
}
: undefined,
})
},
}),
)
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
@ -107,19 +134,21 @@ const read = ReadTool.layer.pipe(
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
)
const sessionID = SessionV2.ID.make("ses_read_tool_test")
@ -174,6 +203,32 @@ describe("ReadTool", () => {
}),
)
it.effect("asks for external_directory approval before reading an external absolute path", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
expect(assertions).toMatchObject([
{
sessionID,
action: "external_directory",
resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
},
{ sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } },
])
}),
)
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="