Compare commits

...
14 changed files with 333 additions and 12 deletions
+1
View File
@@ -3,6 +3,7 @@ import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
export type { PermissionCreateInput } from "./generated/types.js"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
+1
View File
@@ -382,6 +382,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
}),
},
permission: {
assert: permission.assert,
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
+1
View File
@@ -104,6 +104,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
reload: () => Effect.die("unused mcp.reload"),
},
permission: overrides.permission ?? {
assert: () => Effect.die("unused permission.assert"),
hook: () => Effect.die("unused permission.hook"),
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
@@ -0,0 +1,188 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Permission } from "@opencode-ai/core/permission"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/promise/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Cause, Deferred, Effect, Exit, Fiber, Queue } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), [
Location.node.replace(tempLocationLayer),
Config.node.replace(Config.testLayer()),
Mcp.node.replace(emptyMcpLayer),
]),
)
const setup = Effect.gen(function* () {
const database = yield* Database.Service
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const asked = yield* Queue.unbounded<void>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Permission.Event.Asked.type ? Queue.offer(asked, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const ready = yield* Deferred.make<Context>()
yield* plugins.activate([{ id: "permission-test", revision: "1", effect: (ctx) => Deferred.succeed(ready, ctx) }])
const ctx = yield* Deferred.await(ready)
yield* ctx.agent.transform((draft) =>
draft.update("permission-test", (agent) => {
agent.permissions = [
{ action: "deploy", resource: "*", effect: "ask" },
{ action: "deploy", resource: "allowed", effect: "allow" },
{ action: "deploy", resource: "blocked", effect: "deny" },
]
}),
)
const sessionID = Session.ID.create()
yield* database.db
.insert(ProjectTable)
.values({ id: location.project.id, worktree: location.directory, sandboxes: [] })
.onConflictDoNothing()
.run()
yield* database.db
.insert(SessionTable)
.values({
id: sessionID,
project_id: location.project.id,
slug: "permission-test",
directory: location.directory,
title: "Permission test",
version: "test",
agent: "missing",
})
.run()
const input = {
id: Permission.ID.create(),
sessionID,
agent: Agent.ID.make("permission-test"),
action: "deploy",
resources: ["staging"],
save: ["staging"],
metadata: { environment: "staging" },
source: { type: "tool", messageID: "msg_test", id: "call_test" },
} satisfies Permission.AssertInput
return { ctx, input, asked }
})
describe("plugin permission.assert", () => {
it.live("preserves Effect decisions, rejection defects, feedback, and cancellation cleanup", () =>
Effect.gen(function* () {
const { ctx, input, asked } = yield* setup
expect(yield* ctx.permission.assert({ ...input, resources: ["allowed"] })).toBeUndefined()
expect(yield* ctx.permission.assert({ ...input, resources: ["blocked"] }).pipe(Effect.flip)).toBeInstanceOf(
Permission.BlockedError,
)
expect(yield* ctx.permission.list(input)).toEqual([])
yield* Effect.forEach(["once", "reject", "feedback", "cancel"] as const, (reply) =>
Effect.gen(function* () {
const fiber = yield* ctx.permission.assert(input).pipe(Effect.forkScoped)
yield* Queue.take(asked)
expect(fiber.pollUnsafe()).toBeUndefined()
expect(yield* ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
id: input.id,
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
})
if (reply === "cancel") yield* Fiber.interrupt(fiber)
if (reply !== "cancel")
yield* ctx.permission.reply({
sessionID: input.sessionID,
requestID: input.id,
reply: reply === "feedback" ? "reject" : reply,
message: reply === "feedback" ? "Use the test environment" : undefined,
})
const exit = yield* Fiber.await(fiber)
if (reply === "once") expect(exit).toEqual(Exit.succeed(undefined))
if (reply !== "once") {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
if (reply === "cancel") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
if (reply === "reject")
expect(exit.cause.reasons).toContainEqual(
expect.objectContaining({ _tag: "Die", defect: expect.any(Permission.DeclinedError) }),
)
if (reply === "feedback")
expect(exit.cause.reasons).toContainEqual(
expect.objectContaining({
_tag: "Fail",
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
}),
)
}
}
expect(yield* ctx.permission.list(input)).toEqual([])
}),
)
}),
)
it.live("decodes Promise inputs and preserves void results and permission errors through the real host", () =>
Effect.gen(function* () {
const { ctx, input, asked } = yield* setup
yield* PluginPromise.fromPromise(
define({
id: "promise-permission-test",
setup: async (ctx) => {
await expect(
Reflect.apply(ctx.permission.assert, undefined, [{ ...input, resources: [42] }]),
).rejects.toBeDefined()
expect(await ctx.permission.list(input)).toEqual([])
expect(await ctx.permission.assert({ ...input, id: null, resources: ["allowed"] })).toBeUndefined()
await expect(ctx.permission.assert({ ...input, resources: ["blocked"] })).rejects.toBeInstanceOf(
Permission.BlockedError,
)
for (const reply of ["once", "reject", "feedback"] as const) {
const pending = ctx.permission.assert(input)
const settled = pending.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
)
await Effect.runPromise(Queue.take(asked))
expect(await ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
metadata: input.metadata,
source: input.source,
save: input.save,
})
await ctx.permission.reply({
sessionID: input.sessionID,
requestID: input.id,
reply: reply === "feedback" ? "reject" : reply,
...(reply === "feedback" ? { message: "Use the test environment" } : {}),
})
if (reply === "once") expect(await settled).toEqual({ value: undefined })
if (reply === "reject") expect(await settled).toEqual({ error: expect.any(Permission.DeclinedError) })
if (reply === "feedback")
expect(await settled).toEqual({
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
})
expect(await ctx.permission.list(input)).toEqual([])
}
},
}),
).effect(ctx)
}),
)
})
@@ -14,6 +14,7 @@ export const Smoke = Rpc.define({
output: Schema.String,
},
read: { input: Schema.Struct({ path: Schema.String }), output: Schema.String },
deny: { input: Schema.Struct({ urls: Schema.Array(Schema.String) }), output: Schema.Void },
},
events: {},
})
+14
View File
@@ -396,6 +396,11 @@ async function main() {
.find((line) => line.includes('[button] "Frame button"'))
?.match(/@e\d+/)?.[0]
assert(childRef, childSnapshot.content)
await rpc.deny({ urls: [child.url] }, { location })
await fails("evaluate", { tabID, frameID: child.id, script: "document.body.innerText" }, /permission_denied/)
await fails("snapshot", { tabID, frameID: child.id }, /permission_denied/)
await fails("click", { tabID, ref: childRef }, /permission_denied/)
await rpc.deny({ urls: [] }, { location })
await call("click", { tabID, ref: Browser.Ref.make(childRef) })
assert.equal(
(await call("evaluate", { tabID, frameID: child.id, script: "document.querySelector('button').textContent" }))
@@ -446,6 +451,9 @@ async function main() {
assert(network.requests.length)
const detail = await call("network.get", { tabID, id: network.requests[0].id, includeBody: true })
assert.equal(detail.responseBody.state, "text")
await rpc.deny({ urls: [network.requests[0].url] }, { location })
await fails("network.get", { tabID, id: network.requests[0].id, includeBody: true }, /permission_denied/)
await rpc.deny({ urls: [] }, { location })
await fails("network.get", { tabID, id: "unknown-request" }, /Do not reload or resend/)
const fileSnap = await call("snapshot", { tabID })
const input = fileSnap.content
@@ -498,6 +506,12 @@ async function main() {
Buffer.from(await rpc.read({ path: download.files[0].path }, { location }), "base64").toString(),
"desktop download bytes",
)
await call("navigate", { tabID, url: "about:blank" })
await rpc.deny({ urls: [fixture + "/download"] }, { location })
await fails("files.get", { tabID, fileID: downloads.id }, /permission_denied/)
await fails("heap.summary", { tabID, fileID: downloads.id }, /permission_denied/)
await rpc.deny({ urls: [] }, { location })
await call("navigate", { tabID, url: fixture })
await call("evaluate", { tabID, script: "setTimeout(()=>alert('hello dialog'),0); null" })
await until(async () => (await call("dialog", { tabID, action: "get" })).dialog)
await fails("evaluate", { tabID, script: "1" }, /Inspect it with browser\.dialog/)
@@ -13,6 +13,14 @@ export default Plugin.define({
id: "browser.smoke",
effect: (ctx) =>
Effect.gen(function* () {
const denied = new Set<string>()
yield* ctx.permission
.hook("evaluate", (event) =>
Effect.sync(() => {
if (event.action === "browser" && event.resources.some((url) => denied.has(url))) event.effect = "deny"
}),
)
.pipe(Effect.orDie)
const tools = new Map<string, Tool.Info>()
yield* ctx.tool
.transform((editor) => {
@@ -24,6 +32,11 @@ export default Plugin.define({
.pipe(Effect.orDie)
yield* ctx.rpc
.register(Smoke, {
deny: ({ urls }) =>
Effect.sync(() => {
denied.clear()
urls.forEach((url) => denied.add(url))
}),
execute: (input) =>
Effect.gen(function* () {
const result = yield* CodeModeTool.create({ tools }, (name, tool, input, context) =>
+10 -2
View File
@@ -118,8 +118,16 @@ File errors distinguish server-local upload paths from desktop capture files.
Pending/failed downloads and unavailable response bodies are not empty files.
Oversized output requires a smaller request or capture, not an identical retry.
Per-URL and server-file permission checks belong to the final permission layer
(#46530). This base plugin layer intentionally does not enforce those rules.
Browser actions inspect target metadata before using the existing permission
engine. Explicit frame/ref operations authorize their actual frame URLs, network
details authorize the retained request URL before reading bodies, and retained
files authorize their original source URLs even after navigation. The desktop
rejects changed targets rather than executing an approval against new content.
Uploads
also check server-file read permissions and external-directory access against
resolved paths. Network details check the request URL before returning data.
These are action/disclosure checks, not a firewall for every page subresource
or redirect. Browser storage and networking remain on the desktop.
Disable through normal configuration:
+20 -1
View File
@@ -4,6 +4,25 @@ import { Browser } from "./rpc.js"
import { Tool } from "@opencode-ai/schema/tool"
import { Effect } from "effect"
export const resolve = Effect.fn("BrowserFiles.resolve")((inputs: readonly string[], directory: string) =>
Effect.tryPromise({
try: async () => {
const { realpath } = await import("node:fs/promises")
const { resolve, relative, isAbsolute, sep } = await import("node:path")
const root = await realpath(directory)
const paths = await Promise.all(inputs.map((file) => realpath(resolve(directory, file))))
return {
paths,
external: paths.filter((file) => {
const value = relative(root, file)
return value === ".." || value.startsWith(`..${sep}`) || isAbsolute(value)
}),
}
},
catch: (error) => failure("resolve", error),
}),
)
// Files cross machines as bytes. Only this endpoint interprets its local paths.
export const read = Effect.fn("BrowserFiles.read")((paths: readonly string[], directory: string) =>
Effect.tryPromise({
@@ -92,7 +111,7 @@ export function captureName(name: string) {
return sanitized
}
function failure(operation: "read" | "save", error: unknown) {
function failure(operation: "resolve" | "read" | "save", error: unknown) {
const detail = error instanceof Error ? error.message.slice(0, 400) : String(error).slice(0, 400)
const code =
error instanceof Error && "code" in error && typeof error.code === "string" && !detail.startsWith(error.code)
+58 -6
View File
@@ -8,7 +8,7 @@ import { BrowserFiles } from "./files.js"
import { Browser } from "./rpc.js"
export const register = Effect.fn("BrowserTools.register")(function* (
ctx: Pick<Context, "tool" | "location">,
ctx: Pick<Context, "tool" | "location" | "permission">,
connection: BrowserConnection.Connection,
) {
const execute = Effect.fn("BrowserTools.execute")(function* (
@@ -21,12 +21,21 @@ export const register = Effect.fn("BrowserTools.register")(function* (
catch: (error) => new Tool.Error({ message: invalidURL, error }),
})
const target = yield* connection.target(tool.sessionID, action)
const uploads =
action.type === "files.upload" || action.type === "files.drop"
? yield* BrowserFiles.read(action.paths, ctx.location.directory)
: []
const response = yield* target.request(uploads)
const authorize = permissionCheck(ctx.permission, action, target.tab, tool)
const url = action.type === "navigate" || action.type === "tabs.open" ? action.url : target.tab?.url
const inspected = target.tab && !action.type.startsWith("tabs.") ? yield* target.inspect() : undefined
const resources = inspected?.resources ?? (url ? [url] : [])
if (resources.length && !(action.type === "tabs.open" && url === "about:blank"))
yield* authorize("browser", resources)
const uploads = yield* prepareUploads(action, ctx.location.directory, authorize)
const response = yield* target.request(uploads, inspected)
const output = yield* Effect.fromResult(decodeResult(operation, response))
if (action.type === "network.get" && "request" in output && !resources.includes(output.request.url))
yield* authorize("browser", [output.request.url])
if (response.files.length && !("files" in output))
return yield* new Tool.Error({
message: `Browser returned unexpected files for browser.${operation.name}; no files were exported. Check desktop/server plugin compatibility and report the invalid response. Do not repeat the action to repair a protocol error.`,
})
return yield* exportResult(output, response.files)
})
@@ -57,6 +66,49 @@ export const register = Effect.fn("BrowserTools.register")(function* (
.pipe(Effect.orDie)
})
function permissionCheck(
permission: Context["permission"],
action: Browser.Action,
tab: Browser.Tab | undefined,
tool: Tool.Context,
) {
return (name: string, resources: readonly string[]) =>
permission
.assert({
action: name,
resources,
sessionID: tool.sessionID,
agent: tool.agent,
metadata: { type: action.type, ...(tab ? { tabID: tab.id } : {}) },
source: { type: "tool", messageID: tool.messageID, id: tool.id },
})
.pipe(
Effect.mapError((error) => {
const feedback = Schema.decodeUnknownOption(Schema.Struct({ feedback: Schema.String }))(error)
const detail =
feedback._tag === "Some"
? `User feedback: ${feedback.value.feedback}`
: error instanceof Error
? error.message
: String(error)
return new Tool.Error({
message: `[browser.permission_denied] Permission "${name}" was not granted for browser.${action.type}. Do not retry through another tool or change the target to bypass this decision. Follow the user's feedback or ask for an approved action. ${detail}`,
error,
})
}),
)
}
function prepareUploads(action: Browser.Action, directory: string, authorize: ReturnType<typeof permissionCheck>) {
return Effect.gen(function* () {
if (action.type !== "files.upload" && action.type !== "files.drop") return []
const resolved = yield* BrowserFiles.resolve(action.paths, directory)
if (resolved.external.length) yield* authorize("external_directory", resolved.external)
yield* authorize("read", resolved.paths)
return yield* BrowserFiles.read(resolved.paths, directory)
})
}
function decodeResult(operation: Browser.Operation, result: Browser.Result) {
return Result.gen(function* () {
const value = result.files.length
+3 -1
View File
@@ -1,7 +1,8 @@
import type { PermissionApi } from "@opencode-ai/client/effect/api"
import type { PermissionApi, PermissionCreateInput } 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 { Effect } from "effect"
import type { Hooks } from "./registration.js"
export interface PermissionEvaluation {
@@ -20,5 +21,6 @@ export interface PermissionHooks {
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly assert: (input: PermissionCreateInput) => Effect.Effect<void, unknown>
readonly hook: Hooks<PermissionHooks>
}
+5 -1
View File
@@ -259,13 +259,14 @@ export function fromPromise(plugin: Plugin) {
const adaptApiMethod = <PromiseMethod>(
endpoint: HttpApiEndpoint.Top,
method: (input: never) => Effect.Effect<unknown, unknown>,
options?: { readonly noContent?: boolean },
) => {
const compiled = compileEndpoint(endpoint)
return ((input?: unknown) =>
Effect.gen(function* () {
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
const result = yield* method(Object.assign({}, ...decoded) as never)
if (compiled.noContent) return undefined
if (compiled.noContent || options?.noContent) return undefined
return yield* compiled.encode(result)
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
}
@@ -428,6 +429,9 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.mcp.reload()),
},
permission: {
assert: adaptApiMethod(PermissionEndpoints["session.permission.create"], host.permission.assert, {
noContent: true,
}),
hook: (name, callback) =>
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
+2 -1
View File
@@ -1,4 +1,4 @@
import type { PermissionApi } from "@opencode-ai/client/promise/api"
import type { PermissionApi, PermissionCreateInput } 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"
@@ -20,5 +20,6 @@ export interface PermissionHooks {
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
readonly assert: (input: PermissionCreateInput) => Promise<void>
readonly hook: Hooks<PermissionHooks>
}
@@ -648,6 +648,22 @@ const review = await ctx.generate.text({
### Permissions
Assert permission before a plugin action. This plugin-only method uses the generated client's `PermissionCreateInput`
and returns no value: `allow` completes immediately, `ask` waits for a reply, and `deny` fails.
```ts
await ctx.permission.assert({
sessionID,
action: "deploy",
resources: ["staging"],
save: ["staging"],
})
```
Promise plugins receive `Promise<void>`. Effect plugins receive `Effect<void, unknown>` and run it with
`yield* ctx.permission.assert(input)`. Let a rejection propagate so the action does not run. A rejection without feedback
preserves the permission engine's session-interruption behavior; a rejection with feedback carries `Permission.CorrectedError`.
Inspect or resolve pending permission requests.
```ts