mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:51:21 -04:00
feat(plugin): add permission review hooks (#45003)
Co-authored-by: R44VC0RP <R44VC0RP@users.noreply.github.com> Co-authored-by: nexxeln <nexxeln@users.noreply.github.com> Co-authored-by: thdxr <thdxr@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
c94a4913c0
commit
0fd719067d
@@ -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"]
|
||||
|
||||
@@ -1417,6 +1417,7 @@ export type PermissionRequest = {
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type PermissionAsked = {
|
||||
@@ -1433,6 +1434,7 @@ export type PermissionAsked = {
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BlockedError>()("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<boolean, SessionErrors.NotFoundError>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -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<ID, Pending>()
|
||||
|
||||
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<Request, "action" | "resources">, 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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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<Spec> = { readonly [Name in keyof Spec]: never }
|
||||
interface Failures extends Record<keyof Domains, unknown> {
|
||||
readonly aisdk: NoFailures<AISDKHooks>
|
||||
readonly session: NoFailures<SessionHooks>
|
||||
readonly permission: NoFailures<PermissionHooks>
|
||||
readonly shell: NoFailures<ShellHooks>
|
||||
readonly tool: ToolFailures
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Interface {
|
||||
| "interrupt"
|
||||
| "synthetic"
|
||||
| "wait"
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
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)),
|
||||
|
||||
@@ -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}`)),
|
||||
|
||||
@@ -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<Permission.Interface> = {}) =>
|
||||
Layer.mock(Permission.Service, {
|
||||
allowsAll: () => Effect.succeed(false),
|
||||
...overrides,
|
||||
})
|
||||
Layer.mock(Permission.Service, overrides)
|
||||
|
||||
@@ -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<void>()
|
||||
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([
|
||||
|
||||
@@ -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<unknown, never>
|
||||
|
||||
@@ -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")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
let denyAction: string | undefined
|
||||
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => 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",
|
||||
() =>
|
||||
|
||||
@@ -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<string>
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: Permission.Source
|
||||
effect: Permission.Effect
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
@@ -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<unknown>
|
||||
readonly permission: PermissionDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
|
||||
@@ -58,6 +58,7 @@ export type SessionDomain = Pick<
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<string>
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: Permission.Source
|
||||
effect: Permission.Effect
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,7 @@ export type SessionDomain = Pick<
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<SessionInfo>
|
||||
get(input: SessionGetInput, requestOptions?: RequestOptions): Promise<SessionInfo>
|
||||
context(input: SessionContextInput, requestOptions?: RequestOptions): Promise<readonly SessionMessageInfo[]>
|
||||
switchAgent(input: SessionSwitchAgentInput, requestOptions?: RequestOptions): Promise<void>
|
||||
switchModel(input: SessionSwitchModelInput, requestOptions?: RequestOptions): Promise<void>
|
||||
prompt(input: SessionPromptInput, requestOptions?: RequestOptions): Promise<SessionInboxUser>
|
||||
@@ -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<string, unknown>
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user