Răsfoiți Sursa

fix(core): enforce V2 tool permissions (#31061)

Kit Langton 2 luni în urmă
părinte
comite
4814ab3a3d

+ 1 - 1
packages/core/src/session/runner/llm.ts

@@ -219,7 +219,7 @@ export const layer = Layer.effect(
           .filter((part): part is string => part !== undefined && part.length > 0)
           .map(SystemPart.make),
         messages: toLLMMessages(context, model),
-        tools: yield* tools.definitions(),
+        tools: yield* tools.definitions(agent.info?.permissions),
       })
       if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
         return yield* Effect.die(rebuildPreparedTurn())

+ 6 - 1
packages/core/src/tool/question.ts

@@ -1,6 +1,6 @@
 export * as QuestionTool from "./question"
 
-import { Tool, toolText } from "@opencode-ai/llm"
+import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
 import { QuestionV2 } from "../question"
 import { ToolRegistry } from "./registry"
@@ -57,6 +57,11 @@ export const layer = Layer.effectDiscard(
     yield* registry.contribute((editor) =>
       editor.set(name, {
         tool: definition,
+        permission: { action: "question", resource: "*" },
+        authorize: ({ assertPermission }) =>
+          assertPermission({ action: "question", resources: ["*"] }).pipe(
+            Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
+          ),
         execute: ({ parameters, sessionID, source }) =>
           question
             .ask({

+ 25 - 5
packages/core/src/tool/registry.ts

@@ -20,6 +20,7 @@ import type { SessionV2 } from "../session"
 import { ApplicationTools } from "./application-tools"
 import { ToolOutputStore } from "../tool-output-store"
 import { AgentV2 } from "../agent"
+import { Wildcard } from "../util/wildcard"
 
 export type ExecuteInput = {
   readonly sessionID: SessionSchema.ID
@@ -54,6 +55,8 @@ export type Entry<
   Success extends ToolSchema<any> = ToolSchema<any>,
 > = {
   readonly tool: TypedTool<Parameters, Success>
+  /** Catalog visibility only. Execution authorization remains leaf-owned. */
+  readonly permission?: { readonly action: string; readonly resource: "*" }
   readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
   readonly execute?: (
     input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
@@ -78,7 +81,9 @@ export type Editor = {
 export interface Interface {
   readonly transform: State.Interface<Data, Editor>["transform"]
   readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
-  readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
+  readonly definitions: (
+    permissions?: PermissionV2.Ruleset,
+  ) => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
   readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
   readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
 }
@@ -114,13 +119,19 @@ export const layer = Layer.effect(
       }),
     })
 
-    const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
-      const tools = new Map(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const))
+    const definitions = Effect.fn("ToolRegistry.definitions")(function* (permissions: PermissionV2.Ruleset = []) {
+      const tools = new Map(state.get().entries)
       // Location tools own their names. Application tools fill otherwise-unclaimed names.
       for (const [name, tool] of applications.entries()) {
-        if (!tools.has(name)) tools.set(name, tool.definition)
+        if (!tools.has(name)) tools.set(name, { tool: tool.definition })
       }
-      return Tool.toDefinitions(Object.fromEntries(tools))
+      return Tool.toDefinitions(
+        Object.fromEntries(
+          Array.from(tools)
+            .filter(([name, entry]) => !whollyDisabled(entry.permission ?? defaultPermission(name), permissions))
+            .map(([name, entry]) => [name, entry.tool]),
+        ),
+      )
     })
 
     const entry = (name: string): Entry | undefined => {
@@ -224,6 +235,15 @@ export const layer = Layer.effect(
   }),
 )
 
+function defaultPermission(name: string) {
+  return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const }
+}
+
+function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) {
+  const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action))
+  return rule?.resource === "*" && rule.effect === "deny"
+}
+
 export const defaultLayer = layer.pipe(
   Layer.provide(ApplicationTools.layer),
   Layer.provide(ToolOutputStore.defaultLayer),

+ 26 - 1
packages/core/test/application-tools.test.ts

@@ -37,6 +37,26 @@ const contextual = (contexts: Tool.Context[]) =>
   })
 
 describe("ApplicationTools", () => {
+  it.effect("filters an application tool by its name without adding execution authorization", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const contexts: Tool.Context[] = []
+      yield* applications.attach({ application_context: contextual(contexts) })
+
+      expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
+        [],
+      )
+      expect(
+        yield* registry.settle({
+          sessionID,
+          call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
+        }),
+      ).toMatchObject({ result: { type: "content" } })
+      expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
+    }),
+  )
+
   it.effect("advertises and executes a scoped application tool with Session context", () =>
     Effect.gen(function* () {
       const applications = yield* ApplicationTools.Service
@@ -169,13 +189,18 @@ describe("ApplicationTools", () => {
       yield* transform((editor) =>
         editor.set("shared", {
           tool: location.definition,
+          permission: { action: "question", resource: "*" },
           execute: ({ parameters, sessionID, call }) =>
             location.execute(parameters, { sessionID, id: call.id, name: call.name }),
         }),
       )
       yield* applications.attach({ shared: contextual(applicationContexts) })
 
-      expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["shared"])
+      expect(
+        (yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
+          (definition) => definition.name,
+        ),
+      ).toEqual([])
       expect(
         yield* registry.settle({
           sessionID,

+ 2 - 0
packages/core/test/config/config.test.ts

@@ -449,6 +449,7 @@ describe("Config", () => {
                 permission: {
                   bash: "ask",
                   edit: { "*.md": "allow", "*": "deny" },
+                  question: "deny",
                 },
                 agent: {
                   reviewer: {
@@ -526,6 +527,7 @@ describe("Config", () => {
               { action: "bash", resource: "*", effect: "ask" },
               { action: "edit", resource: "*.md", effect: "allow" },
               { action: "edit", resource: "*", effect: "deny" },
+              { action: "question", resource: "*", effect: "deny" },
             ])
             expect(documents[0]?.info.agents?.reviewer).toMatchObject({
               system: "Review changes.",

+ 90 - 0
packages/core/test/session-runner-tool-registry.test.ts

@@ -45,6 +45,96 @@ const echo = Tool.make({
 })
 
 describe("ToolRegistry", () => {
+  it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
+    Effect.gen(function* () {
+      const registry = yield* ToolRegistry.Service
+      const transform = yield* registry.transform()
+      const sessionID = SessionV2.ID.make("ses_registry_filter")
+      yield* transform((editor) => {
+        editor.set("question", { tool: echo })
+        editor.set("bash", { tool: echo })
+        editor.set("edit", { tool: echo })
+        editor.set("write", { tool: echo })
+        editor.set("apply_patch", { tool: echo })
+      })
+
+      const names = (rules: PermissionV2.Ruleset) =>
+        registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
+
+      expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
+        "bash",
+        "edit",
+        "write",
+        "apply_patch",
+      ])
+
+      expect(
+        yield* names([
+          { action: "*", resource: "*", effect: "deny" },
+          { action: "question", resource: "private", effect: "allow" },
+        ]),
+      ).toEqual(["question"])
+
+      expect(
+        yield* names([
+          { action: "question", resource: "private", effect: "allow" },
+          { action: "*", resource: "*", effect: "deny" },
+        ]),
+      ).toEqual([])
+
+      expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question")
+      expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
+      expect(
+        yield* names([
+          { action: "edit", resource: "*", effect: "deny" },
+          { action: "edit", resource: "*.md", effect: "ask" },
+        ]),
+      ).toEqual(["question", "bash", "edit", "write", "apply_patch"])
+      expect(
+        yield* names([
+          { action: "edit", resource: "*.md", effect: "allow" },
+          { action: "edit", resource: "*", effect: "deny" },
+        ]),
+      ).toEqual(["question", "bash"])
+    }),
+  )
+
+  it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
+    Effect.gen(function* () {
+      const registry = yield* ToolRegistry.Service
+      const transform = yield* registry.transform()
+      const sessionID = SessionV2.ID.make("ses_registry_stale")
+      let executed = false
+      yield* transform((editor) =>
+        editor.set("question", {
+          tool: echo,
+          permission: { action: "question", resource: "*" },
+          authorize: ({ assertPermission }) =>
+            assertPermission({ action: "question", resources: ["actual"] }).pipe(
+              Effect.mapError(() => new ToolFailure({ message: "Denied" })),
+            ),
+          execute: () =>
+            Effect.sync(() => {
+              executed = true
+              return { text: "unexpected" }
+            }),
+        }),
+      )
+
+      expect(
+        (yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
+      ).toEqual([])
+      expect(
+        yield* registry.settle({
+          sessionID,
+          call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
+        }),
+      ).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
+      expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
+      expect(executed).toBe(true)
+    }),
+  )
+
   it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
     Effect.gen(function* () {
       const registry = yield* ToolRegistry.Service

+ 1 - 0
packages/core/test/tool-bash.test.ts

@@ -149,6 +149,7 @@ describe("BashTool", () => {
             const definitions = yield* registry.definitions()
             expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
             expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
+            expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
             expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
               result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
               output: {

+ 1 - 0
packages/core/test/tool-edit.test.ts

@@ -110,6 +110,7 @@ describe("EditTool", () => {
             withTool(tmp.path, (registry) =>
               Effect.gen(function* () {
                 expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
+                expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([])
                 const settled = yield* registry.settle(
                   call({ path: "hello.txt", oldString: "before", newString: "after" }),
                 )

+ 27 - 2
packages/core/test/tool-question.test.ts

@@ -11,11 +11,15 @@ const sessionID = SessionV2.ID.make("ses_question_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 let captured: QuestionV2.AskInput | undefined
 let reject = false
+let deny = false
 const capturedInput = () => captured
 const permission = Layer.succeed(
   PermissionV2.Service,
   PermissionV2.Service.of({
-    assert: (input) => Effect.sync(() => assertions.push(input)),
+    assert: (input) =>
+      Effect.sync(() => assertions.push(input)).pipe(
+        Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
+      ),
     ask: () => Effect.die("unused"),
     reply: () => Effect.die("unused"),
     get: () => Effect.die("unused"),
@@ -40,11 +44,30 @@ const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(ques
 const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
 
 describe("QuestionTool", () => {
+  it.effect("omits a denied built-in question and terminally settles a stale call", () =>
+    Effect.gen(function* () {
+      captured = undefined
+      deny = true
+      const registry = yield* ToolRegistry.Service
+
+      expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
+      expect(
+        yield* registry.settle({
+          sessionID,
+          call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
+        }),
+      ).toEqual({ result: { type: "error", value: "Permission denied: question" } })
+      expect(capturedInput()).toBeUndefined()
+      deny = false
+    }),
+  )
+
   it.effect("registers question and projects user answers without a permission assertion", () =>
     Effect.gen(function* () {
       assertions.length = 0
       captured = undefined
       reject = false
+      deny = false
       const registry = yield* ToolRegistry.Service
       const questions = [
         {
@@ -81,7 +104,7 @@ describe("QuestionTool", () => {
           ],
         },
       })
-      expect(assertions).toEqual([])
+      expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }])
       expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
     }),
   )
@@ -90,6 +113,7 @@ describe("QuestionTool", () => {
     Effect.gen(function* () {
       captured = undefined
       reject = false
+      deny = false
       const registryService = yield* ToolRegistry.Service
 
       yield* registryService.execute({
@@ -104,6 +128,7 @@ describe("QuestionTool", () => {
     Effect.gen(function* () {
       captured = undefined
       reject = true
+      deny = false
       const registryService = yield* ToolRegistry.Service
       const fiber = yield* registryService
         .execute({

+ 1 - 0
packages/core/test/tool-read.test.ts

@@ -101,6 +101,7 @@ describe("ReadTool", () => {
       const registry = yield* ToolRegistry.Service
 
       expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
+      expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
       expect(
         yield* registry.execute({
           sessionID,