From 423fad730c99333fd3cdc1b00a7369f687494227 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:24:23 -0500 Subject: [PATCH] fix(core): authorize external glob paths (#38714) --- packages/core/src/tool/glob.ts | 22 +++-- packages/core/test/tool-search.test.ts | 112 +++++++++++++++++++++---- 2 files changed, 115 insertions(+), 19 deletions(-) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 3da274fcf1..edcf98d7f1 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -7,6 +7,7 @@ import path from "path" import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" +import { LocationMutation } from "../location-mutation" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" @@ -45,6 +46,7 @@ export const Plugin = { const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service + const mutation = yield* LocationMutation.Service const permission = yield* PermissionV2.Service yield* ctx.tool @@ -58,6 +60,16 @@ export const Plugin = { output: Output, execute: (input, context) => Effect.gen(function* () { + const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID } + const target = yield* mutation.resolve({ path: input.path ?? ".", kind: "directory" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) yield* permission.assert({ action: name, resources: [input.pattern], @@ -69,20 +81,20 @@ export const Plugin = { }, sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.messageID, callID: context.callID }, + source, }) - const cwd = path.resolve(location.directory, input.path ?? ".") yield* fs - .stat(cwd) + .stat(target.canonical) .pipe( Effect.catchReason("PlatformError", "NotFound", () => Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), ), ) + const root = path.resolve(location.directory, input.path ?? ".") const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT const entries = yield* ripgrep .glob({ - cwd, + cwd: target.canonical, pattern: input.pattern, limit: limit + 1, }) @@ -91,7 +103,7 @@ export const Plugin = { result.map((entry) => FileSystem.Entry.make({ ...entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), + path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))), }), ), ), diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 7f924a94dd..022599e884 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "@opencode-ai/core/location" +import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -24,27 +25,27 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" const globToolNode = makeLocationNode({ name: "test/glob-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), - deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], + deps: [ + ToolRegistry.toolsNode, + FSUtil.node, + Ripgrep.node, + Location.node, + LocationMutation.node, + PermissionV2.node, + ], }) const grepToolNode = makeLocationNode({ name: "test/grep-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], }) -const permission = Layer.succeed( - PermissionV2.Service, - PermissionV2.Service.of({ - assert: () => Effect.void, - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) const sessionID = SessionV2.ID.make("ses_search_tool_test") -const withTools = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => +const withTools = ( + directory: string, + body: (registry: ToolRegistry.Interface) => Effect.Effect, + assertions?: PermissionV2.AssertInput[], +) => Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) }).pipe( @@ -54,7 +55,23 @@ const withTools = (directory: string, body: (registry: ToolRegistry.Int Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), ], - [PermissionV2.node, permission], + [ + PermissionV2.node, + Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: (input) => + Effect.sync(() => { + assertions?.push(input) + }), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), + ), + ], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ]), ), @@ -125,4 +142,71 @@ describe("search tools", () => { ), ) } + + it.live("requires external_directory approval for an explicit external glob path", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + const assertions: PermissionV2.AssertInput[] = [] + return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")).pipe( + Effect.andThen( + withTools( + active.path, + (registry) => executeTool(registry, call("glob", { path: outside.path, pattern: "*.txt" })), + assertions, + ), + ), + Effect.tap((result) => + Effect.sync(() => { + expect(result.status).toBe("completed") + expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"]) + expect(assertions[0]?.resources).toEqual([ + path.join(outside.path, "*").replaceAll("\\", "/"), + ]) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("globs through an in-location external symlink without external approval", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + if (process.platform === "win32") return Effect.void + const assertions: PermissionV2.AssertInput[] = [] + return Effect.promise(async () => { + await fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n") + await fs.symlink(outside.path, path.join(active.path, "linked")) + }).pipe( + Effect.andThen( + withTools( + active.path, + (registry) => executeTool(registry, call("glob", { path: "linked", pattern: "*.txt" })), + assertions, + ), + ), + Effect.tap((result) => + Effect.sync(() => { + expect(result.status).toBe("completed") + expect(assertions.map((input) => input.action)).toEqual(["glob"]) + expect(result).toMatchObject({ + output: [{ path: path.join("linked", "outside.txt"), type: "file" }], + content: [{ type: "text", text: path.join(active.path, "linked", "outside.txt") }], + }) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) })