fix(core): enforce V2 tool permissions (#31061)
This commit is contained in:
parent
747b8daafc
commit
4814ab3a3d
10 changed files with 180 additions and 10 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue