ソースを参照

refactor: collapse permission barrel into permission/index.ts (#22915)

Kit Langton 3 時間 前
コミット
964474a1b1

+ 325 - 1
packages/opencode/src/permission/index.ts

@@ -1 +1,325 @@
-export * as Permission from "./permission"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { ConfigPermission } from "@/config/permission"
+import { InstanceState } from "@/effect"
+import { ProjectID } from "@/project/schema"
+import { MessageID, SessionID } from "@/session/schema"
+import { PermissionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage"
+import { zod } from "@/util/effect-zod"
+import { Log } from "@/util"
+import { withStatics } from "@/util/schema"
+import { Wildcard } from "@/util"
+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: ConfigPermission.Info) {
+  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 "."

+ 0 - 323
packages/opencode/src/permission/permission.ts

@@ -1,323 +0,0 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { ConfigPermission } from "@/config/permission"
-import { InstanceState } from "@/effect"
-import { ProjectID } from "@/project/schema"
-import { MessageID, SessionID } from "@/session/schema"
-import { PermissionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage"
-import { zod } from "@/util/effect-zod"
-import { Log } from "@/util"
-import { withStatics } from "@/util/schema"
-import { Wildcard } from "@/util"
-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: ConfigPermission.Info) {
-  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))