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),
+ ),
+ ),
+ )
})