summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/permission/index.ts326
-rw-r--r--packages/opencode/src/permission/permission.ts323
2 files changed, 324 insertions, 325 deletions
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index 010048549..7d8a2fff8 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -1,325 +1 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { Config } from "@/config"
-import { InstanceState } from "@/effect/instance-state"
-import { ProjectID } from "@/project/schema"
-import { MessageID, SessionID } from "@/session/schema"
-import { PermissionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage/db"
-import { zod } from "@/util/effect-zod"
-import { Log } from "@/util/log"
-import { withStatics } from "@/util/schema"
-import { Wildcard } from "@/util/wildcard"
-import { Deferred, Effect, Layer, Schema, Context } from "effect"
-import os from "os"
-import { evaluate as evalRule } from "./evaluate"
-import { PermissionID } from "./schema"
-
-export namespace Permission {
- const log = Log.create({ service: "permission" })
-
- export const Action = Schema.Literals(["allow", "deny", "ask"])
- .annotate({ identifier: "PermissionAction" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type Action = Schema.Schema.Type<typeof Action>
-
- export class Rule extends Schema.Class<Rule>("PermissionRule")({
- permission: Schema.String,
- pattern: Schema.String,
- action: Action,
- }) {
- static readonly zod = zod(this)
- }
-
- export const Ruleset = Schema.mutable(Schema.Array(Rule))
- .annotate({ identifier: "PermissionRuleset" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type Ruleset = Schema.Schema.Type<typeof Ruleset>
-
- export class Request extends Schema.Class<Request>("PermissionRequest")({
- id: PermissionID,
- sessionID: SessionID,
- permission: Schema.String,
- patterns: Schema.Array(Schema.String),
- metadata: Schema.Record(Schema.String, Schema.Unknown),
- always: Schema.Array(Schema.String),
- tool: Schema.optional(
- Schema.Struct({
- messageID: MessageID,
- callID: Schema.String,
- }),
- ),
- }) {
- static readonly zod = zod(this)
- }
-
- export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
- export type Reply = Schema.Schema.Type<typeof Reply>
-
- const reply = {
- reply: Reply,
- message: Schema.optional(Schema.String),
- }
-
- export const ReplyBody = Schema.Struct(reply)
- .annotate({ identifier: "PermissionReplyBody" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
-
- export class Approval extends Schema.Class<Approval>("PermissionApproval")({
- projectID: ProjectID,
- patterns: Schema.Array(Schema.String),
- }) {
- static readonly zod = zod(this)
- }
-
- export const Event = {
- Asked: BusEvent.define("permission.asked", Request.zod),
- Replied: BusEvent.define(
- "permission.replied",
- zod(
- Schema.Struct({
- sessionID: SessionID,
- requestID: PermissionID,
- reply: Reply,
- }),
- ),
- ),
- }
-
- export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
- override get message() {
- return "The user rejected permission to use this specific tool call."
- }
- }
-
- export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
- feedback: Schema.String,
- }) {
- override get message() {
- return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
- }
- }
-
- export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
- ruleset: Schema.Any,
- }) {
- override get message() {
- return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
- }
- }
-
- export type Error = DeniedError | RejectedError | CorrectedError
-
- export const AskInput = Schema.Struct({
- ...Request.fields,
- id: Schema.optional(PermissionID),
- ruleset: Ruleset,
- })
- .annotate({ identifier: "PermissionAskInput" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type AskInput = Schema.Schema.Type<typeof AskInput>
-
- export const ReplyInput = Schema.Struct({
- requestID: PermissionID,
- ...reply,
- })
- .annotate({ identifier: "PermissionReplyInput" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
-
- export interface Interface {
- readonly ask: (input: AskInput) => Effect.Effect<void, Error>
- readonly reply: (input: ReplyInput) => Effect.Effect<void>
- readonly list: () => Effect.Effect<ReadonlyArray<Request>>
- }
-
- interface PendingEntry {
- info: Request
- deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
- }
-
- interface State {
- pending: Map<PermissionID, PendingEntry>
- approved: Ruleset
- }
-
- export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
- log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
- return evalRule(permission, pattern, ...rulesets)
- }
-
- export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const bus = yield* Bus.Service
- const state = yield* InstanceState.make<State>(
- Effect.fn("Permission.state")(function* (ctx) {
- const row = Database.use((db) =>
- db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
- )
- const state = {
- pending: new Map<PermissionID, PendingEntry>(),
- approved: row?.data ?? [],
- }
-
- yield* Effect.addFinalizer(() =>
- Effect.gen(function* () {
- for (const item of state.pending.values()) {
- yield* Deferred.fail(item.deferred, new RejectedError())
- }
- state.pending.clear()
- }),
- )
-
- return state
- }),
- )
-
- const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
- const { approved, pending } = yield* InstanceState.get(state)
- const { ruleset, ...request } = input
- let needsAsk = false
-
- for (const pattern of request.patterns) {
- const rule = evaluate(request.permission, pattern, ruleset, approved)
- log.info("evaluated", { permission: request.permission, pattern, action: rule })
- if (rule.action === "deny") {
- return yield* new DeniedError({
- ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
- })
- }
- if (rule.action === "allow") continue
- needsAsk = true
- }
-
- if (!needsAsk) return
-
- const id = request.id ?? PermissionID.ascending()
- const info = Schema.decodeUnknownSync(Request)({
- id,
- ...request,
- })
- log.info("asking", { id, permission: info.permission, patterns: info.patterns })
-
- const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
- pending.set(id, { info, deferred })
- yield* bus.publish(Event.Asked, info)
- return yield* Effect.ensuring(
- Deferred.await(deferred),
- Effect.sync(() => {
- pending.delete(id)
- }),
- )
- })
-
- const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
- const { approved, pending } = yield* InstanceState.get(state)
- const existing = pending.get(input.requestID)
- if (!existing) return
-
- pending.delete(input.requestID)
- yield* bus.publish(Event.Replied, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- reply: input.reply,
- })
-
- if (input.reply === "reject") {
- yield* Deferred.fail(
- existing.deferred,
- input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
- )
-
- for (const [id, item] of pending.entries()) {
- if (item.info.sessionID !== existing.info.sessionID) continue
- pending.delete(id)
- yield* bus.publish(Event.Replied, {
- sessionID: item.info.sessionID,
- requestID: item.info.id,
- reply: "reject",
- })
- yield* Deferred.fail(item.deferred, new RejectedError())
- }
- return
- }
-
- yield* Deferred.succeed(existing.deferred, undefined)
- if (input.reply === "once") return
-
- for (const pattern of existing.info.always) {
- approved.push({
- permission: existing.info.permission,
- pattern,
- action: "allow",
- })
- }
-
- for (const [id, item] of pending.entries()) {
- if (item.info.sessionID !== existing.info.sessionID) continue
- const ok = item.info.patterns.every(
- (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
- )
- if (!ok) continue
- pending.delete(id)
- yield* bus.publish(Event.Replied, {
- sessionID: item.info.sessionID,
- requestID: item.info.id,
- reply: "always",
- })
- yield* Deferred.succeed(item.deferred, undefined)
- }
- })
-
- const list = Effect.fn("Permission.list")(function* () {
- const pending = (yield* InstanceState.get(state)).pending
- return Array.from(pending.values(), (item) => item.info)
- })
-
- return Service.of({ ask, reply, list })
- }),
- )
-
- function expand(pattern: string): string {
- if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
- if (pattern === "~") return os.homedir()
- if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
- if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
- return pattern
- }
-
- export function fromConfig(permission: Config.Permission) {
- const ruleset: Ruleset = []
- for (const [key, value] of Object.entries(permission)) {
- if (typeof value === "string") {
- ruleset.push({ permission: key, action: value, pattern: "*" })
- continue
- }
- ruleset.push(
- ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
- )
- }
- return ruleset
- }
-
- export function merge(...rulesets: Ruleset[]): Ruleset {
- return rulesets.flat()
- }
-
- const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
-
- export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
- const result = new Set<string>()
- for (const tool of tools) {
- const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
- const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
- if (!rule) continue
- if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
- }
- return result
- }
-
- export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
-}
+export * as Permission from "./permission"
diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts
new file mode 100644
index 000000000..a5f6ded32
--- /dev/null
+++ b/packages/opencode/src/permission/permission.ts
@@ -0,0 +1,323 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Config } from "@/config"
+import { InstanceState } from "@/effect/instance-state"
+import { ProjectID } from "@/project/schema"
+import { MessageID, SessionID } from "@/session/schema"
+import { PermissionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage/db"
+import { zod } from "@/util/effect-zod"
+import { Log } from "@/util/log"
+import { withStatics } from "@/util/schema"
+import { Wildcard } from "@/util/wildcard"
+import { Deferred, Effect, Layer, Schema, Context } from "effect"
+import os from "os"
+import { evaluate as evalRule } from "./evaluate"
+import { PermissionID } from "./schema"
+
+const log = Log.create({ service: "permission" })
+
+export const Action = Schema.Literals(["allow", "deny", "ask"])
+ .annotate({ identifier: "PermissionAction" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Action = Schema.Schema.Type<typeof Action>
+
+export class Rule extends Schema.Class<Rule>("PermissionRule")({
+ permission: Schema.String,
+ pattern: Schema.String,
+ action: Action,
+}) {
+ static readonly zod = zod(this)
+}
+
+export const Ruleset = Schema.mutable(Schema.Array(Rule))
+ .annotate({ identifier: "PermissionRuleset" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Ruleset = Schema.Schema.Type<typeof Ruleset>
+
+export class Request extends Schema.Class<Request>("PermissionRequest")({
+ id: PermissionID,
+ sessionID: SessionID,
+ permission: Schema.String,
+ patterns: Schema.Array(Schema.String),
+ metadata: Schema.Record(Schema.String, Schema.Unknown),
+ always: Schema.Array(Schema.String),
+ tool: Schema.optional(
+ Schema.Struct({
+ messageID: MessageID,
+ callID: Schema.String,
+ }),
+ ),
+}) {
+ static readonly zod = zod(this)
+}
+
+export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Reply = Schema.Schema.Type<typeof Reply>
+
+const reply = {
+ reply: Reply,
+ message: Schema.optional(Schema.String),
+}
+
+export const ReplyBody = Schema.Struct(reply)
+ .annotate({ identifier: "PermissionReplyBody" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
+
+export class Approval extends Schema.Class<Approval>("PermissionApproval")({
+ projectID: ProjectID,
+ patterns: Schema.Array(Schema.String),
+}) {
+ static readonly zod = zod(this)
+}
+
+export const Event = {
+ Asked: BusEvent.define("permission.asked", Request.zod),
+ Replied: BusEvent.define(
+ "permission.replied",
+ zod(
+ Schema.Struct({
+ sessionID: SessionID,
+ requestID: PermissionID,
+ reply: Reply,
+ }),
+ ),
+ ),
+}
+
+export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+ override get message() {
+ return "The user rejected permission to use this specific tool call."
+ }
+}
+
+export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+ feedback: Schema.String,
+}) {
+ override get message() {
+ return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+ }
+}
+
+export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+ ruleset: Schema.Any,
+}) {
+ override get message() {
+ return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+ }
+}
+
+export type Error = DeniedError | RejectedError | CorrectedError
+
+export const AskInput = Schema.Struct({
+ ...Request.fields,
+ id: Schema.optional(PermissionID),
+ ruleset: Ruleset,
+})
+ .annotate({ identifier: "PermissionAskInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type AskInput = Schema.Schema.Type<typeof AskInput>
+
+export const ReplyInput = Schema.Struct({
+ requestID: PermissionID,
+ ...reply,
+})
+ .annotate({ identifier: "PermissionReplyInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
+
+export interface Interface {
+ readonly ask: (input: AskInput) => Effect.Effect<void, Error>
+ readonly reply: (input: ReplyInput) => Effect.Effect<void>
+ readonly list: () => Effect.Effect<ReadonlyArray<Request>>
+}
+
+interface PendingEntry {
+ info: Request
+ deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+}
+
+interface State {
+ pending: Map<PermissionID, PendingEntry>
+ approved: Ruleset
+}
+
+export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+ log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
+ return evalRule(permission, pattern, ...rulesets)
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const bus = yield* Bus.Service
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Permission.state")(function* (ctx) {
+ const row = Database.use((db) =>
+ db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
+ )
+ const state = {
+ pending: new Map<PermissionID, PendingEntry>(),
+ approved: row?.data ?? [],
+ }
+
+ yield* Effect.addFinalizer(() =>
+ Effect.gen(function* () {
+ for (const item of state.pending.values()) {
+ yield* Deferred.fail(item.deferred, new RejectedError())
+ }
+ state.pending.clear()
+ }),
+ )
+
+ return state
+ }),
+ )
+
+ const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
+ const { approved, pending } = yield* InstanceState.get(state)
+ const { ruleset, ...request } = input
+ let needsAsk = false
+
+ for (const pattern of request.patterns) {
+ const rule = evaluate(request.permission, pattern, ruleset, approved)
+ log.info("evaluated", { permission: request.permission, pattern, action: rule })
+ if (rule.action === "deny") {
+ return yield* new DeniedError({
+ ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
+ })
+ }
+ if (rule.action === "allow") continue
+ needsAsk = true
+ }
+
+ if (!needsAsk) return
+
+ const id = request.id ?? PermissionID.ascending()
+ const info = Schema.decodeUnknownSync(Request)({
+ id,
+ ...request,
+ })
+ log.info("asking", { id, permission: info.permission, patterns: info.patterns })
+
+ const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+ pending.set(id, { info, deferred })
+ yield* bus.publish(Event.Asked, info)
+ return yield* Effect.ensuring(
+ Deferred.await(deferred),
+ Effect.sync(() => {
+ pending.delete(id)
+ }),
+ )
+ })
+
+ const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
+ const { approved, pending } = yield* InstanceState.get(state)
+ const existing = pending.get(input.requestID)
+ if (!existing) return
+
+ pending.delete(input.requestID)
+ yield* bus.publish(Event.Replied, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ reply: input.reply,
+ })
+
+ if (input.reply === "reject") {
+ yield* Deferred.fail(
+ existing.deferred,
+ input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+ )
+
+ for (const [id, item] of pending.entries()) {
+ if (item.info.sessionID !== existing.info.sessionID) continue
+ pending.delete(id)
+ yield* bus.publish(Event.Replied, {
+ sessionID: item.info.sessionID,
+ requestID: item.info.id,
+ reply: "reject",
+ })
+ yield* Deferred.fail(item.deferred, new RejectedError())
+ }
+ return
+ }
+
+ yield* Deferred.succeed(existing.deferred, undefined)
+ if (input.reply === "once") return
+
+ for (const pattern of existing.info.always) {
+ approved.push({
+ permission: existing.info.permission,
+ pattern,
+ action: "allow",
+ })
+ }
+
+ for (const [id, item] of pending.entries()) {
+ if (item.info.sessionID !== existing.info.sessionID) continue
+ const ok = item.info.patterns.every(
+ (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
+ )
+ if (!ok) continue
+ pending.delete(id)
+ yield* bus.publish(Event.Replied, {
+ sessionID: item.info.sessionID,
+ requestID: item.info.id,
+ reply: "always",
+ })
+ yield* Deferred.succeed(item.deferred, undefined)
+ }
+ })
+
+ const list = Effect.fn("Permission.list")(function* () {
+ const pending = (yield* InstanceState.get(state)).pending
+ return Array.from(pending.values(), (item) => item.info)
+ })
+
+ return Service.of({ ask, reply, list })
+ }),
+)
+
+function expand(pattern: string): string {
+ if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
+ if (pattern === "~") return os.homedir()
+ if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
+ if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
+ return pattern
+}
+
+export function fromConfig(permission: Config.Permission) {
+ const ruleset: Ruleset = []
+ for (const [key, value] of Object.entries(permission)) {
+ if (typeof value === "string") {
+ ruleset.push({ permission: key, action: value, pattern: "*" })
+ continue
+ }
+ ruleset.push(
+ ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
+ )
+ }
+ return ruleset
+}
+
+export function merge(...rulesets: Ruleset[]): Ruleset {
+ return rulesets.flat()
+}
+
+const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
+
+export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+ const result = new Set<string>()
+ for (const tool of tools) {
+ const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+ const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
+ if (!rule) continue
+ if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
+ }
+ return result
+}
+
+export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))