diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index 129b9585c26..3db663122c3 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -4,10 +4,12 @@ export type AgentApi = Client["agent"] export type CommandApi = Client["command"] export type ConfigApi = Client["config"] export type EventApi = Client["event"] +export type GenerateApi = Client["generate"] export type IntegrationApi = Client["integration"] export type McpApi = Client["mcp"] export type ModelApi = Client["model"] export type PluginApi = Client["plugin"] +export type PermissionApi = Client["permission"] export type ProviderApi = Client["provider"] export type ReferenceApi = Client["reference"] export type WebSearchApi = Client["websearch"] diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 6e3f0969334..122631fb271 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1417,6 +1417,7 @@ export type PermissionRequest = { save?: Array metadata?: { [x: string]: JsonValue } source?: PermissionSource + message?: string } export type PermissionAsked = { @@ -1433,6 +1434,7 @@ export type PermissionAsked = { save?: Array metadata?: { [x: string]: any } source?: PermissionSource + message?: string } } diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 5c35f89b9f8..d64c2d0c376 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema.js" import { SessionStore } from "./session/store.js" import { Wildcard } from "./util/wildcard.js" import { PermissionSaved } from "./permission/saved.js" +import { PluginHooks } from "./plugin/hooks.js" const PermissionEffect = Permission.Effect export { PermissionEffect as Effect } @@ -70,9 +71,10 @@ export class BlockedError extends Schema.TaggedError()("Permission rules: Permission.Ruleset, permission: Schema.String, resources: Schema.Array(Schema.String), + reason: Schema.String.pipe(Schema.optional), }) { override get message() { - return `Permission denied: ${this.permission}` + return this.reason ?? `Permission denied: ${this.permission}` } } @@ -99,11 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { } export interface Interface { - readonly allowsAll: (input: { - readonly sessionID: SessionSchema.ID - readonly action: string - readonly agent?: Agent.ID - }) => Effect.Effect readonly ask: (input: AssertInput) => Effect.Effect readonly assert: (input: AssertInput) => Effect.Effect readonly reply: (input: ReplyInput) => Effect.Effect @@ -128,6 +125,7 @@ const layer = Layer.effect( const agents = yield* Agent.Service const sessions = yield* SessionStore.Service const saved = yield* PermissionSaved.Service + const hooks = yield* PluginHooks.Service const pending = new Map() yield* Effect.addFinalizer(() => @@ -159,24 +157,6 @@ const layer = Layer.effect( return agent?.permissions ?? missingAgentPermissions }) - const allowsAll = Effect.fnUntraced(function* (input: { - readonly sessionID: SessionSchema.ID - readonly action: string - readonly agent?: Agent.ID - }) { - const rules = yield* configured(input.sessionID, input.agent) - const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action)) - for (let index = relevant.length - 1; index >= 0; index--) { - const rule = relevant[index] - if (rule.resource !== "*") { - if (rule.effect !== "allow") return false - continue - } - return rule.effect === "allow" - } - return false - }) - function denied(input: Pick, rules: Permission.Ruleset) { return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny") } @@ -191,10 +171,19 @@ const layer = Layer.effect( const all = [...rules, ...(yield* savedRules())] const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect) const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" - return { effect, rules: all } + const event = yield* hooks.trigger("permission", "evaluate", { + sessionID: input.sessionID, + agent: input.agent, + action: input.action, + resources: input.resources, + metadata: input.metadata, + source: input.source, + effect, + }) + return { effect: event.effect, message: event.message, rules: all } }) - function request(input: AssertInput): Request { + function request(input: AssertInput, message?: string): Request { return { id: input.id ?? ID.create(), sessionID: input.sessionID, @@ -203,6 +192,7 @@ const layer = Layer.effect( save: input.save, metadata: input.metadata, source: input.source, + message, } } @@ -223,39 +213,42 @@ const layer = Layer.effect( const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) { const result = yield* evaluateInput(input) - const value = request(input) + const value = request(input, result.message) if (result.effect === "ask") yield* create(value, input.agent) return { id: value.id, effect: result.effect } }) const assert = Effect.fn("Permission.assert")((input: AssertInput) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const result = yield* evaluateInput(input) - if (result.effect === "deny") { - return yield* new BlockedError({ - rules: relevant(input, result.rules), - permission: input.action, - resources: input.resources, - }) - } - if (result.effect === "allow") return - const item = yield* create(request(input), input.agent) - return yield* restore(Deferred.await(item.deferred)).pipe( - // Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which - // must not convert a user's decline into model-facing tool output. The decline - // resurfaces as a typed failure at SessionModelRequest.executeTool. A decline - // WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn - // it into ToolFailure and the model continues. - Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)), - Effect.ensuring( - Effect.sync(() => { - pending.delete(item.request.id) - }), - ), - ) - }), - ), + Effect.gen(function* () { + const result = yield* evaluateInput(input) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if (result.effect === "deny") { + return yield* new BlockedError({ + rules: relevant(input, result.rules), + permission: input.action, + resources: input.resources, + reason: result.message, + }) + } + if (result.effect === "allow") return + const item = yield* create(request(input, result.message), input.agent) + return yield* restore(Deferred.await(item.deferred)).pipe( + // Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which + // must not convert a user's decline into model-facing tool output. The decline + // resurfaces as a typed failure at SessionModelRequest.executeTool. A decline + // WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn + // it into ToolFailure and the model continues. + Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)), + Effect.ensuring( + Effect.sync(() => { + pending.delete(item.request.id) + }), + ), + ) + }), + ) + }), ) const reply = Effect.fn("Permission.reply")((input: ReplyInput) => @@ -337,12 +330,12 @@ const layer = Layer.effect( return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) }) - return Service.of({ allowsAll, ask, assert, reply, get, forSession, list }) + return Service.of({ ask, assert, reply, get, forSession, list }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node], + deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node], }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 37e0c1bd1a7..e22453306f4 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -24,6 +24,8 @@ import { State } from "./state.js" import { Tool } from "./tool.js" import { Vcs } from "./vcs.js" import { PluginHooks } from "./plugin/hooks.js" +import { Generate } from "./generate.js" +import { Permission } from "./permission.js" export interface Interface { readonly activate: ( @@ -204,5 +206,7 @@ export const node = makeLocationNode({ PluginHooks.node, PluginRuntime.node, WebSearch.node, + Generate.node, + Permission.node, ], }) diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index c9b5abba369..a849efab368 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -5,6 +5,7 @@ import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool" import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration" +import type { PermissionHooks } from "@opencode-ai/plugin/effect/permission" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { State } from "../state.js" @@ -12,6 +13,7 @@ import { State } from "../state.js" export interface Domains { readonly aisdk: AISDKHooks readonly session: SessionHooks + readonly permission: PermissionHooks readonly shell: ShellHooks readonly tool: ToolHooks } @@ -22,6 +24,7 @@ type NoFailures = { readonly [Name in keyof Spec]: never } interface Failures extends Record { readonly aisdk: NoFailures readonly session: NoFailures + readonly permission: NoFailures readonly shell: NoFailures readonly tool: ToolFailures } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 09e9b745ae3..f1f9ef6e121 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -26,6 +26,8 @@ import { Tool } from "../tool.js" import { Workspace } from "../workspace.js" import { Vcs } from "../vcs.js" import { WebSearch } from "../websearch.js" +import { Generate } from "../generate.js" +import { Permission } from "../permission.js" import { PluginHooks } from "./hooks.js" import type { Interface } from "../plugin.js" @@ -46,6 +48,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p const tools = yield* Tool.Service const vcs = yield* Vcs.Service const websearch = yield* WebSearch.Service + const generate = yield* Generate.Service + const permission = yield* Permission.Service const hooks = yield* PluginHooks.Service const runtime = yield* PluginRuntime.Service const locationInfo = () => @@ -188,6 +192,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)), }, + generate: { + text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))), + }, integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), @@ -313,6 +320,30 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p }) }), }, + permission: { + hook: (name, callback) => hooks.register("permission", name, callback), + list: (input) => permission.forSession(input.sessionID), + get: (input) => + permission + .get(input.requestID) + .pipe( + Effect.flatMap((request) => + request?.sessionID === input.sessionID + ? Effect.succeed(request) + : Effect.fail(new Error(`Permission request not found: ${input.requestID}`)), + ), + ), + reply: (input) => + permission + .get(input.requestID) + .pipe( + Effect.flatMap((request) => + request?.sessionID === input.sessionID + ? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message }) + : Effect.fail(new Error(`Permission request not found: ${input.requestID}`)), + ), + ), + }, plugin: { list: () => response(plugin.list()), }, @@ -417,6 +448,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p .interrupt(input.sessionID, { continue: input.continue }) .pipe(Effect.map((interrupted) => ({ interrupted }))), wait: (input) => runtime.session.wait(input.sessionID), + context: (input) => runtime.session.context(input.sessionID), }, } satisfies Plugin.Context }) diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index 1956eb650a8..6e232196251 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -26,6 +26,7 @@ export interface Interface { | "interrupt" | "synthetic" | "wait" + | "context" > readonly job: Pick readonly location: { @@ -81,6 +82,7 @@ export const layerWithCell = (cell: Cell) => interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)), synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)), wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)), + context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)), }, job: { start: (input) => require(cell, (runtime) => runtime.job.start(input)), diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index f9dd1da03c2..75d25d46767 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -194,54 +194,40 @@ export const Plugin = { (invocation) => Effect.gen(function* () { const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) - const unrestricted = - (yield* permission.allowsAll({ - sessionID: context.sessionID, - action: name, - agent: context.agent, - })) && - (yield* permission.allowsAll({ - sessionID: context.sessionID, - action: "external_directory", - agent: context.agent, - })) invocation.cwd = target.absolute finalTimeout = invocation.timeout - if (!unrestricted) { - const portable = - Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true - const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { - portable, - }) - const directories = yield* Effect.forEach(parsed.directories, (directory) => - mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }), + const portable = + Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true + const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { + portable, + }) + const directories = yield* Effect.forEach(parsed.directories, (directory) => + mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }), + ) + const external = [target, ...directories] + .map((item) => item.externalDirectory) + .filter((item) => item !== undefined) + .filter( + (item, index, items) => items.findIndex((other) => other.resource === item.resource) === index, ) - const external = [target, ...directories] - .map((item) => item.externalDirectory) - .filter((item) => item !== undefined) - .filter( - (item, index, items) => - items.findIndex((other) => other.resource === item.resource) === index, - ) - if (external.length > 0) - yield* permission.assert({ - action: "external_directory", - resources: external.map((item) => item.resource), - save: external.map((item) => item.save), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - if (parsed.commands.length > 0) - yield* permission.assert({ - action: name, - resources: parsed.commands.map((command) => command.resource), - save: parsed.commands.map((command) => command.save), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - } + if (external.length > 0) + yield* permission.assert({ + action: "external_directory", + resources: external.map((item) => item.resource), + save: external.map((item) => item.save), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + if (parsed.commands.length > 0) + yield* permission.assert({ + action: name, + resources: parsed.commands.map((command) => command.resource), + save: parsed.commands.map((command) => command.save), + sessionID: context.sessionID, + agent: context.agent, + source, + }) const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe( Effect.catchTag("Environment.NotFound", () => Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)), diff --git a/packages/core/test/lib/permission.ts b/packages/core/test/lib/permission.ts index 292d118112c..63787794ecf 100644 --- a/packages/core/test/lib/permission.ts +++ b/packages/core/test/lib/permission.ts @@ -1,8 +1,5 @@ import { Permission } from "@opencode-ai/core/permission" -import { Effect, Layer } from "effect" +import { Layer } from "effect" export const permissionLayer = (overrides: Partial = {}) => - Layer.mock(Permission.Service, { - allowsAll: () => Effect.succeed(false), - ...overrides, - }) + Layer.mock(Permission.Service, overrides) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index ac83bf7f28e..06abfe59475 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Cause, Deferred, Effect, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -10,6 +10,7 @@ import { Location } from "@opencode-ai/core/location" import { Permission } from "@opencode-ai/core/permission" import { PermissionTable } from "@opencode-ai/core/permission/sql" import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -26,7 +27,15 @@ const current = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]), + LayerNode.group([ + Database.node, + Bus.node, + SessionStore.node, + PermissionSaved.node, + Agent.node, + PluginHooks.node, + Permission.node, + ]), [[Location.node, current]], ), ) @@ -112,31 +121,6 @@ describe("Permission", () => { }), ) - it.effect("proves only unconditional configured allows", () => - Effect.gen(function* () { - const service = yield* Permission.Service - const input = { sessionID: Session.ID.make("ses_test"), action: "shell" } - - yield* setup([{ action: "shell", resource: "*", effect: "allow" }]) - expect(yield* service.allowsAll(input)).toBe(true) - - yield* setRules([ - { action: "shell", resource: "*", effect: "allow" }, - { action: "shell", resource: "rm *", effect: "deny" }, - ]) - expect(yield* service.allowsAll(input)).toBe(false) - - yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }]) - expect(yield* service.allowsAll(input)).toBe(false) - - yield* setRules([ - { action: "shell", resource: "rm *", effect: "deny" }, - { action: "shell", resource: "*", effect: "allow" }, - ]) - expect(yield* service.allowsAll(input)).toBe(true) - }), - ) - it.effect("evaluates against an explicit provider-turn agent", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) @@ -172,6 +156,74 @@ describe("Permission", () => { }), ) + it.effect("lets plugins review allow and ask decisions without overriding configured denies", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const seen: string[] = [] + yield* hooks.register("permission", "evaluate", (event) => + Effect.sync(() => { + seen.push(event.effect) + event.effect = event.action === "write" ? "deny" : "allow" + event.message = "Reviewed by policy" + }), + ) + const service = yield* Permission.Service + + yield* setup([{ action: "read", resource: "*", effect: "allow" }]) + expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) + + yield* setRules([]) + expect(yield* service.ask(assertion({ id: Permission.ID.create("per_ask") }))).toMatchObject({ effect: "allow" }) + expect(yield* service.list()).toEqual([]) + + const blocked = yield* service + .assert(assertion({ id: Permission.ID.create("per_write"), action: "write" })) + .pipe(Effect.flip) + expect(blocked).toBeInstanceOf(Permission.BlockedError) + expect(blocked.message).toBe("Reviewed by policy") + + yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) + expect(yield* service.ask(assertion({ id: Permission.ID.create("per_deny") }))).toMatchObject({ effect: "deny" }) + expect(seen).toEqual(["allow", "ask", "ask"]) + }), + ) + + it.effect("publishes the reviewer message when a plugin escalates to ask", () => + Effect.gen(function* () { + yield* setup([{ action: "read", resource: "*", effect: "allow" }]) + const hooks = yield* PluginHooks.Service + yield* hooks.register("permission", "evaluate", (event) => + Effect.sync(() => { + event.effect = "ask" + event.message = "Confirm production access" + }), + ) + const service = yield* Permission.Service + const result = yield* service.ask(assertion()) + + expect(result.effect).toBe("ask") + expect(yield* service.get(result.id)).toMatchObject({ message: "Confirm production access" }) + }), + ) + + it.effect("allows cancellation while a permission reviewer is running", () => + Effect.gen(function* () { + yield* setup([{ action: "read", resource: "*", effect: "allow" }]) + const hooks = yield* PluginHooks.Service + const started = yield* Deferred.make() + yield* hooks.register("permission", "evaluate", () => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + ) + const service = yield* Permission.Service + const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped) + + yield* Deferred.await(started) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) + }), + ) + it.effect("allows managed output reads without granting external directory access", () => Effect.gen(function* () { yield* setup([ diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index afc0535dddf..183c47e9bb5 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -10,6 +10,7 @@ import { Bus } from "@opencode-ai/core/bus" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Form } from "@opencode-ai/core/form" +import { Generate } from "@opencode-ai/core/generate" import { Integration } from "@opencode-ai/core/integration" import { KV } from "@opencode-ai/core/kv" import { Location } from "@opencode-ai/core/location" @@ -18,6 +19,7 @@ import { Npm } from "@opencode-ai/util/npm" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { Permission } from "@opencode-ai/core/permission" import { Reference } from "@opencode-ai/core/reference" import { Skill } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" @@ -38,6 +40,20 @@ const npmLayer = Layer.succeed( }), ) +const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") })) + +const permissionLayer = Layer.succeed( + Permission.Service, + Permission.Service.of({ + ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }), + assert: () => Effect.void, + reply: () => Effect.void, + get: () => Effect.succeed(undefined), + forSession: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }), +) + export const PluginTestLayer = LayerNode.compile( LayerNode.group([ FileSystem.node, @@ -47,6 +63,7 @@ export const PluginTestLayer = LayerNode.compile( Credential.node, Bus.node, Form.node, + Generate.node, LayerNodePlatform.httpClient, Plugin.node, Agent.node, @@ -57,6 +74,7 @@ export const PluginTestLayer = LayerNode.compile( KV.node, MCP.node, PluginRuntime.node, + Permission.node, PluginHooks.node, Reference.node, Skill.node, @@ -72,5 +90,7 @@ export const PluginTestLayer = LayerNode.compile( [Npm.node, npmLayer], [Config.node, Config.testLayer()], [MCP.node, emptyMcpLayer], + [Generate.node, generateLayer], + [Permission.node, permissionLayer], ], ) as unknown as Layer.Layer diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index ea59072edb7..9b049ac0af3 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -48,6 +48,9 @@ export function host(overrides: Overrides = {}): Plugin.Context { event: overrides.event ?? { subscribe: () => Stream.empty, }, + generate: overrides.generate ?? { + text: () => Effect.die("unused generate.text"), + }, integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), @@ -81,6 +84,12 @@ export function host(overrides: Overrides = {}): Plugin.Context { transform: () => Effect.die("unused mcp.transform"), reload: () => Effect.die("unused mcp.reload"), }, + permission: overrides.permission ?? { + hook: () => Effect.die("unused permission.hook"), + list: () => Effect.die("unused permission.list"), + get: () => Effect.die("unused permission.get"), + reply: () => Effect.die("unused permission.reply"), + }, plugin: overrides.plugin ?? { list: () => Effect.die("unused plugin.list"), }, @@ -134,6 +143,7 @@ export function host(overrides: Overrides = {}): Plugin.Context { synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")), interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")), wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")), + context: overrides.session?.context ?? (() => Effect.die("unused session.context")), }, } } diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index c66b1ea2518..f22de91bd8d 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -46,12 +46,10 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const sessionID = Session.ID.make("ses_shell_tool_test") const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") }) const assertions: Permission.AssertInput[] = [] -const allowedActions = new Set() let denyAction: string | undefined let afterPermission = (_input: Permission.AssertInput): Effect.Effect => Effect.void const permission = permissionLayer({ - allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)), assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen(Effect.suspend(() => afterPermission(input))), @@ -71,7 +69,6 @@ const permission = permissionLayer({ const reset = () => { assertions.length = 0 - allowedActions.clear() denyAction = undefined afterPermission = () => Effect.void } @@ -365,30 +362,6 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) - it.live( - "skips command decomposition when shell and external directories are unrestricted", - () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - allowedActions.add("shell") - allowedActions.add("external_directory") - return withSession(tmp.path, (registry) => - executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")), - ).pipe( - Effect.andThen( - Effect.sync(() => { - expect(assertions).toEqual([]) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), - ), - { timeout: 15_000 }, - ) - it.live( "captures stderr-only and mixed stdout/stderr output", () => diff --git a/packages/plugin/src/effect/permission.ts b/packages/plugin/src/effect/permission.ts new file mode 100644 index 00000000000..01f72484c46 --- /dev/null +++ b/packages/plugin/src/effect/permission.ts @@ -0,0 +1,24 @@ +import type { PermissionApi } from "@opencode-ai/client/effect/api" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Permission } from "@opencode-ai/schema/permission" +import type { Session } from "@opencode-ai/schema/session" +import type { Hooks } from "./registration.js" + +export interface PermissionEvaluation { + readonly sessionID: Session.ID + readonly agent?: Agent.ID + readonly action: string + readonly resources: ReadonlyArray + readonly metadata?: Record + readonly source?: Permission.Source + effect: Permission.Effect + message?: string +} + +export interface PermissionHooks { + readonly evaluate: PermissionEvaluation +} + +export type PermissionDomain = Pick, "list" | "get" | "reply"> & { + readonly hook: Hooks +} diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 9a9205d7258..0969b925c3e 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -1,4 +1,4 @@ -import type { PluginApi } from "@opencode-ai/client/effect/api" +import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" import type { App } from "../app.js" @@ -9,6 +9,7 @@ import type { CommandDomain } from "./command.js" import type { EventDomain } from "./event.js" import type { IntegrationDomain } from "./integration.js" import type { MCPDomain } from "./mcp.js" +import type { PermissionDomain } from "./permission.js" import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" import type { ShellDomain } from "./shell.js" @@ -28,6 +29,8 @@ export interface Context { readonly event: EventDomain readonly integration: IntegrationDomain readonly mcp: MCPDomain + readonly generate: GenerateApi + readonly permission: PermissionDomain readonly plugin: PluginApi readonly reference: ReferenceDomain readonly session: SessionDomain diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 965aaea3508..a07fea8b005 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -58,6 +58,7 @@ export type SessionDomain = Pick< | "interrupt" | "rename" | "wait" + | "context" > & { readonly hook: ModelHooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 8d0f19d31d1..e25ff94526f 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -76,10 +76,12 @@ export function fromPromise(plugin: Plugin) { ) const AgentEndpoints = ClientApi.groups["server.agent"].endpoints const CommandEndpoints = ClientApi.groups["server.command"].endpoints + const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints const McpEndpoints = ClientApi.groups["server.mcp"].endpoints const ModelEndpoints = ClientApi.groups["server.model"].endpoints const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints + const PermissionEndpoints = ClientApi.groups["server.permission"].endpoints const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints const SessionEndpoints = ClientApi.groups["server.session"].endpoints @@ -174,6 +176,9 @@ export function fromPromise(plugin: Plugin) { ), ), }, + generate: { + text: adaptApiMethod(GenerateEndpoints["generate.text"], host.generate.text), + }, integration: { list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list), get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get), @@ -261,6 +266,13 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.mcp), reload: () => run(host.mcp.reload()), }, + permission: { + hook: (name, callback) => + register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list), + get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get), + reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply), + }, plugin: { list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list), }, @@ -355,6 +367,7 @@ export function fromPromise(plugin: Plugin) { interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt), rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename), wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait), + context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context), }, shell: { hook: (name, callback) => diff --git a/packages/plugin/src/promise/permission.ts b/packages/plugin/src/promise/permission.ts new file mode 100644 index 00000000000..8dcfdb1e779 --- /dev/null +++ b/packages/plugin/src/promise/permission.ts @@ -0,0 +1,24 @@ +import type { PermissionApi } from "@opencode-ai/client/promise/api" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Permission } from "@opencode-ai/schema/permission" +import type { Session } from "@opencode-ai/schema/session" +import type { Hooks } from "./registration.js" + +export interface PermissionEvaluation { + readonly sessionID: Session.ID + readonly agent?: Agent.ID + readonly action: string + readonly resources: ReadonlyArray + readonly metadata?: Record + readonly source?: Permission.Source + effect: Permission.Effect + message?: string +} + +export interface PermissionHooks { + readonly evaluate: PermissionEvaluation +} + +export type PermissionDomain = Pick & { + readonly hook: Hooks +} diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 559cdb2e118..fa6234f8bfc 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -1,4 +1,4 @@ -import type { PluginApi } from "@opencode-ai/client/promise/api" +import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" @@ -8,6 +8,7 @@ import type { CommandDomain } from "./command.js" import type { EventDomain } from "./event.js" import type { IntegrationDomain } from "./integration.js" import type { MCPDomain } from "./mcp.js" +import type { PermissionDomain } from "./permission.js" import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" import type { ShellDomain } from "./shell.js" @@ -27,6 +28,8 @@ export interface Context { readonly event: EventDomain readonly integration: IntegrationDomain readonly mcp: MCPDomain + readonly generate: GenerateApi + readonly permission: PermissionDomain readonly plugin: PluginApi readonly reference: ReferenceDomain readonly session: SessionDomain diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index e87a5e0441f..570d1fa57ec 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -58,6 +58,7 @@ export type SessionDomain = Pick< | "interrupt" | "rename" | "wait" + | "context" > & { readonly hook: ModelHooks } diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts index 2faebd42416..e978c946329 100644 --- a/packages/schema/src/permission.ts +++ b/packages/schema/src/permission.ts @@ -29,6 +29,7 @@ const RequestFields = { save: Schema.Array(Schema.String).pipe(optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), source: Source.pipe(optional), + message: Schema.String.pipe(optional), } export const Request = Schema.Struct({ diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index d5cb949875f..28de9671f9d 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -613,6 +613,27 @@ interface ReferenceDraft { } ``` +### Generate + +Generate text with a selected model without creating a session, invoking tools, or adding to session history. + +```ts +const review = await ctx.generate.text({ + model: { providerID: "anthropic", id: "claude-sonnet-4-6" }, + prompt: "Review this proposed action.", +}) +``` + +### Permissions + +Inspect or resolve pending permission requests. + +```ts +const pending = await ctx.permission.list({ sessionID }) +const request = await ctx.permission.get({ sessionID, requestID }) +await ctx.permission.reply({ sessionID, requestID, reply: "once" }) +``` + ### Sessions Create or read a session. @@ -620,6 +641,7 @@ Create or read a session. ```ts const created = await ctx.session.create({ title: "Review" }) const session = await ctx.session.get({ sessionID }) +const messages = await ctx.session.context({ sessionID }) ``` Change the agent or model used by subsequent requests. @@ -655,6 +677,7 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M interface SessionContext { create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise get(input: SessionGetInput, requestOptions?: RequestOptions): Promise + context(input: SessionContextInput, requestOptions?: RequestOptions): Promise switchAgent(input: SessionSwitchAgentInput, requestOptions?: RequestOptions): Promise switchModel(input: SessionSwitchModelInput, requestOptions?: RequestOptions): Promise prompt(input: SessionPromptInput, requestOptions?: RequestOptions): Promise @@ -995,6 +1018,46 @@ interface SessionHookContext { } ``` +### Permissions + +Review permission decisions after configured rules are evaluated and before an action runs or a permission prompt is +published. Hooks run for `allow` and `ask` decisions. An explicit configured `deny` is final and does not invoke the +hook. + +```ts +await ctx.permission.hook("evaluate", async (event) => { + if (event.action === "read") return + + const messages = await ctx.session.context({ sessionID: event.sessionID }) + const review = await ctx.generate.text({ + model: { providerID: "anthropic", id: "claude-sonnet-4-6" }, + prompt: buildSafetyPrompt({ messages, action: event.action, resources: event.resources }), + }) + const decision = parseDecision(review.text) + + event.effect = decision.effect + event.message = decision.reason +}) +``` + +The hook may set `effect` to `allow`, `ask`, or `deny`. When set, `message` is included in an escalated permission +request or used as the denial reason. + +#### Reference + +```ts +interface PermissionEvaluation { + readonly sessionID: string + readonly agent?: string + readonly action: string + readonly resources: readonly string[] + readonly metadata?: Record + readonly source?: { type: "tool"; messageID: string; id: string } + effect: "allow" | "ask" | "deny" + message?: string +} +``` + ### Shell Modify shell commands, working directories, timeouts, executables, or environment variables before execution.