Просмотр исходного кода

fix(core): authorize external glob paths (#38714)

Aiden Cline 2 недель назад
Родитель
Сommit
423fad730c
2 измененных файлов с 115 добавлено и 19 удалено
  1. 17 5
      packages/core/src/tool/glob.ts
  2. 98 14
      packages/core/test/tool-search.test.ts

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

+ 98 - 14
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 = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
+const withTools = <A, E, R>(
+  directory: string,
+  body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
+  assertions?: PermissionV2.AssertInput[],
+) =>
   Effect.gen(function* () {
     return yield* body(yield* ToolRegistry.Service)
   }).pipe(
@@ -54,7 +55,23 @@ const withTools = <A, E, R>(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),
+        ),
+    ),
+  )
 })