feat(core): add location-based permission service (#30287)

This commit is contained in:
Dax 2026-06-01 21:32:50 -04:00 committed by GitHub
parent acd620f411
commit 9b815bcbd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 4970 additions and 552 deletions

View File

@ -0,0 +1 @@
DROP TABLE `permission`;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
CREATE TABLE `permission` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`action` text NOT NULL,
`resource` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `permission_project_action_resource_idx` ON `permission` (`project_id`,`action`,`resource`);

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
@ -26,7 +26,7 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionV2.Ruleset,
permissions: PermissionSchema.Ruleset,
}) {
static empty(id: ID) {
return new Info({

View File

@ -24,5 +24,7 @@ export const migrations = (
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View File

@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260601202201_amazing_prowler",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`permission\`;`)
})
},
} satisfies DatabaseMigration.Migration

View File

@ -0,0 +1,22 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260602002951_lowly_union_jack",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`permission\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`action\` text NOT NULL,
\`resource\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
})
},
} satisfies DatabaseMigration.Migration

View File

@ -13,6 +13,10 @@ import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { SessionV2 } from "./session"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
@ -25,6 +29,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Catalog.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
PermissionV2.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",
@ -36,5 +41,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
ModelsDev.defaultLayer,
AppFileSystem.defaultLayer,
Global.defaultLayer,
Database.defaultLayer,
SessionV2.defaultLayer,
PermissionSaved.defaultLayer,
],
}) {}

View File

@ -1,42 +1,110 @@
export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { EventV2 } from "./event"
import { Location } from "./location"
import { AgentV2 } from "./agent"
import { SessionV2 } from "./session"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard"
import { Identifier } from "./id/id"
import { Newtype } from "./schema"
import { PermissionSchema } from "./permission/schema"
import { PermissionSaved } from "./permission/saved"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}
export { Effect, Rule, Ruleset } from "./permission/schema"
type Effect = PermissionSchema.Effect
type Rule = PermissionSchema.Rule
type Ruleset = PermissionSchema.Ruleset
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionV2.ID"),
withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Source = Schema.Union([
Schema.Struct({
type: Schema.Literal("tool"),
messageID: Schema.String,
callID: Schema.String,
}),
]).annotate({ identifier: "PermissionV2.Source" })
export type Source = typeof Source.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.Request" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.AssertInput" })
export type AssertInput = typeof AssertInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.ReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export const AskResult = Schema.Struct({
id: ID,
effect: PermissionSchema.Effect,
}).annotate({ identifier: "PermissionV2.AskResult" })
export type AskResult = typeof AskResult.Type
export const Event = {
Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
Replied: EventV2.define({
type: "permission.v2.replied",
schema: {
sessionID: SessionV2.ID,
requestID: ID,
reply: Reply,
},
}),
}
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "Permission.Action" })
export type Action = typeof Action.Type
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "Permission.Rule" })
export type Rule = typeof Rule.Type
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" })
export type Ruleset = typeof Ruleset.Type
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: PermissionSchema.Ruleset,
}) {}
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID,
}) {}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
export type Error = DeniedError | RejectedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
}
)
}
@ -45,12 +113,189 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
export interface Interface {
readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}
interface Pending {
readonly request: Request
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const agents = yield* AgentV2.Service
const sessions = yield* SessionV2.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
yield* EffectRuntime.addFinalizer(() =>
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true,
}).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.clear()
}),
),
),
)
const savedRules = EffectRuntime.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
)
})
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
const session = yield* sessions.get(sessionID)
if (!session.agent) return []
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
})
function denied(input: AssertInput, rules: Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})
function request(input: AssertInput): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
}
}
const create = EffectRuntime.fnUntraced(function* (request: Request) {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, deferred }
pending.set(request.id, item)
yield* events.publish(Event.Asked, request)
return item
})
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
if (result.effect === "ask") yield* create(value)
return { id: value.id, effect: result.effect }
})
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
rules: relevant(input, result.rules),
})
}
if (result.effect === "allow") return
const item = yield* create(request(input))
return yield* Deferred.await(item.deferred).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.delete(item.request.id)
}),
),
)
})
const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.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) {
if (item.request.sessionID !== existing.request.sessionID) continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({ projectID: location.project.id, action: existing.request.action, resources: existing.request.save })
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every((resource) => evaluate(item.request.action, resource, effective).effect === "allow")
)
continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)
})
const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) {
return pending.get(id)?.request
})
const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))

View File

@ -0,0 +1,96 @@
export * as PermissionLegacy from "./legacy"
import { Schema } from "effect"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { SessionSchema } from "../session/schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = typeof Ruleset.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionSchema.ID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).pipe(Schema.optional),
}).annotate({ identifier: "PermissionRequest" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = typeof Approval.Type
export const AskInput = Schema.Struct({
...Request.fields,
id: ID.pipe(Schema.optional),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = typeof AskInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
...ReplyBody.fields,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
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 class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: ID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError

View File

@ -0,0 +1,78 @@
export * as PermissionSaved from "./saved"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { PermissionTable } from "./sql"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
projectID: ProjectV2.ID,
action: Schema.String,
resource: Schema.String,
}).annotate({ identifier: "PermissionSaved.Info" })
export type Info = typeof Info.Type
export const ListInput = Schema.Struct({
projectID: ProjectV2.ID.pipe(Schema.optional),
}).annotate({ identifier: "PermissionSaved.ListInput" })
export type ListInput = typeof ListInput.Type
export const AddInput = Schema.Struct({
projectID: ProjectV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionSaved.AddInput" })
export type AddInput = typeof AddInput.Type
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly add: (input: AddInput) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PermissionSaved") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const rows = yield* db
.select()
.from(PermissionTable)
.where(input?.projectID ? eq(PermissionTable.project_id, input.projectID) : undefined)
.all()
.pipe(Effect.orDie)
return rows.map((row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource }))
})
const add = Effect.fn("PermissionSaved.add")(function* (input: AddInput) {
if (!input.resources.length) return
yield* db
.insert(PermissionTable)
.values(input.resources.map((resource) => ({ id: ID.create(), project_id: input.projectID, action: input.action, resource })))
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("PermissionSaved.remove")(function* (id: ID) {
yield* db.delete(PermissionTable).where(eq(PermissionTable.id, id)).run().pipe(Effect.orDie)
})
return Service.of({ list, add, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

View File

@ -0,0 +1,16 @@
export * as PermissionSchema from "./schema"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
export type Effect = typeof Effect.Type
export const Rule = Schema.Struct({
action: Schema.String,
resource: Schema.String,
effect: Effect,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type

View File

@ -0,0 +1,20 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
import { ProjectTable } from "../project/sql"
import type { PermissionSaved } from "./saved"
export const PermissionTable = sqliteTable(
"permission",
{
id: text().$type<PermissionSaved.ID>().primaryKey(),
project_id: text()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
action: text().notNull(),
resource: text().notNull(),
...Timestamps,
},
(table) => [uniqueIndex("permission_project_action_resource_idx").on(table.project_id, table.action, table.resource)],
)

View File

@ -104,23 +104,23 @@ export const Plugin = PluginV2.define({
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ permission: "external_directory", pattern: "*", action: "ask" },
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }),
(resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: PermissionV2.Ruleset = [
{ permission: "*", pattern: "*", action: "allow" },
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ permission: "question", pattern: "*", action: "deny" },
{ permission: "plan_enter", pattern: "*", action: "deny" },
{ permission: "plan_exit", pattern: "*", action: "deny" },
{ permission: "repo_clone", pattern: "*", action: "deny" },
{ permission: "repo_overview", pattern: "*", action: "deny" },
{ permission: "read", pattern: "*", action: "allow" },
{ permission: "read", pattern: "*.env", action: "ask" },
{ permission: "read", pattern: "*.env.*", action: "ask" },
{ permission: "read", pattern: "*.env.example", action: "allow" },
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "repo_clone", resource: "*", effect: "deny" },
{ action: "repo_overview", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* agent.update((editor) => {
@ -129,8 +129,8 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" },
{ permission: "plan_enter", pattern: "*", action: "allow" },
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
})
@ -140,15 +140,15 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" },
{ permission: "plan_exit", pattern: "*", action: "allow" },
{ permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
{ permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" },
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
permission: "edit",
pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
action: "allow",
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
)
@ -159,7 +159,7 @@ export const Plugin = PluginV2.define({
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(
...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]),
...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]),
)
})
@ -172,14 +172,14 @@ export const Plugin = PluginV2.define({
...PermissionV2.merge(
defaults,
[
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "grep", pattern: "*", action: "allow" },
{ permission: "glob", pattern: "*", action: "allow" },
{ permission: "list", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "webfetch", pattern: "*", action: "allow" },
{ permission: "websearch", pattern: "*", action: "allow" },
{ permission: "read", pattern: "*", action: "allow" },
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "list", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
],
readonlyExternalDirectory,
),
@ -190,21 +190,21 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
editor.update(AgentV2.ID.make("title"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
editor.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
})
}),

View File

@ -2,7 +2,7 @@ export * as SessionLegacy from "./legacy"
import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event"
import { PermissionV2 } from "../permission"
import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { optionalOmitUndefined, withStatics } from "../schema"
@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV2.Ruleset),
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type

View File

@ -3,7 +3,7 @@ import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Snapshot } from "../snapshot"
import { PermissionV2 } from "../permission"
import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy"
@ -42,7 +42,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<PermissionV2.Ruleset>(),
permission: text({ mode: "json" }).$type<PermissionLegacy.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
@ -129,11 +129,3 @@ export const SessionMessageTable = sqliteTable(
index("session_message_time_created_idx").on(table.time_created),
],
)
export const PermissionTable = sqliteTable("permission", {
project_id: text()
.primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<PermissionV2.Ruleset>(),
})

View File

@ -19,7 +19,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
yield* defaults((editor) =>
editor.update(build, (agent) => {
agent.mode = "primary"
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
}),
)
@ -30,16 +30,16 @@ describe("ConfigAgentPlugin.Plugin", () => {
new Config.Loaded({
source: { type: "memory" },
info: decode({
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: {
build: {
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
},
removed: { description: "Removed later" },
},
@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git *", action: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "*", action: "deny" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),

View File

@ -170,8 +170,8 @@ describe("Config", () => {
enterprise: { url: "https://share.example.com" },
username: "test-user",
permissions: [
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git status", effect: "allow" },
],
agents: {
reviewer: {
@ -188,7 +188,7 @@ describe("Config", () => {
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
},
},
snapshots: false,
@ -254,8 +254,8 @@ describe("Config", () => {
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user")
expect(documents[0]?.info.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git status", effect: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toEqual({
model: "openrouter/openai/gpt-5",
@ -271,7 +271,7 @@ describe("Config", () => {
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })

View File

@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
}),
)
})

View File

@ -0,0 +1,176 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const events = EventV2.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(Layer.provide(database))
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database),
Layer.provideMerge(events),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(saved),
)
const it = testEffect(layer)
function setup(rules: PermissionV2.Ruleset = []) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: SessionV2.ID.make("ses_test"),
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* setRules(rules)
})
}
function setRules(rules: PermissionV2.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const update = yield* agents.transform()
yield* update((editor) =>
editor.update(AgentV2.ID.make("test"), (agent) => {
agent.permissions = [...rules]
}),
)
})
}
function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
return {
id: PermissionV2.ID.create("per_test"),
sessionID: SessionV2.ID.make("ses_test"),
action: "read",
resources: ["src/index.ts"],
...input,
} satisfies PermissionV2.AssertInput
}
function waitForRequest() {
return Effect.gen(function* () {
const service = yield* PermissionV2.Service
const events = yield* EventV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
return { service, fiber, request }
})
}
describe("PermissionV2", () => {
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
yield* setRules([])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("resolves an asked permission once", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
expect(yield* service.list()).toEqual([request])
expect(yield* service.forSession(request.sessionID)).toEqual([request])
expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([])
expect(yield* service.get(request.id)).toEqual(request)
yield* service.reply({ requestID: request.id, reply: "once" })
yield* Fiber.join(fiber)
expect(yield* service.list()).toEqual([])
expect(yield* service.get(request.id)).toBeUndefined()
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* PermissionV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
yield* service.reply({ requestID: request.id, reply: "always" })
yield* Fiber.join(fiber)
const { db } = yield* Database.Service
expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all()).toMatchObject([
{ action: "read", resource: "src/*" },
])
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([
{ id, projectID: Project.ID.global, action: "read", resource: "src/*" },
])
yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
}),
)
})

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
@ -36,7 +37,7 @@ export const Info = Schema.Struct({
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ProviderV2.ModelID,

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Permission } from "../permission"
import type { Agent } from "./agent"
@ -15,10 +16,10 @@ import type { Agent } from "./agent"
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentSessionPermission: PermissionLegacy.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
}): PermissionLegacy.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies =

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EOL } from "os"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { basename } from "path"
@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
ask(req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
throw new PermissionLegacy.DeniedError({ ruleset })
}
}
})

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
// CLI entry point for `opencode run`.
//
// Handles three modes:
@ -367,7 +368,7 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: Permission.Ruleset = args.interactive
const rules: PermissionLegacy.Ruleset = args.interactive
? []
: [
{

View File

@ -1 +1 @@
export { evaluate } from "@opencode-ai/core/permission"
export { evaluate } from "."

View File

@ -1,142 +1,53 @@
import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Deferred, Effect, Layer, Context } from "effect"
import os from "os"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "permission" })
export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
export type Action = Schema.Schema.Type<typeof Action>
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = Schema.Schema.Type<typeof Rule>
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
// Pure data; nothing checks class identity. As `Schema.Struct` + type alias,
// `Permission.ask` can trust its already-typed input and skip the inner
// `decodeUnknownSync` that would otherwise throw uncaught on any structural
// mismatch. Same pattern as `Question.Request` in PR #28570.
export const Request = Schema.Struct({
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,
}),
),
}).annotate({ identifier: "PermissionRequest" })
export type Request = Schema.Schema.Type<typeof Request>
export const Reply = Schema.Literals(["once", "always", "reject"])
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" })
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = Schema.Schema.Type<typeof Approval>
export const Event = {
Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }),
Asked: EventV2.define({ type: "permission.asked", schema: PermissionLegacy.Request.fields }),
Replied: EventV2.define({
type: "permission.replied",
schema: {
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
sessionID: PermissionLegacy.Request.fields.sessionID,
requestID: PermissionLegacy.ID,
reply: PermissionLegacy.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 class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: PermissionID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Schema.Struct({
...Request.fields,
id: Schema.optional(PermissionID),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = Schema.Schema.Type<typeof AskInput>
export const ReplyInput = Schema.Struct({
requestID: PermissionID,
...reply,
}).annotate({ identifier: "PermissionReplyInput" })
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, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly ask: (input: PermissionLegacy.AskInput) => Effect.Effect<void, PermissionLegacy.Error>
readonly reply: (input: PermissionLegacy.ReplyInput) => Effect.Effect<void, PermissionLegacy.NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<PermissionLegacy.Request>>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
info: PermissionLegacy.Request
deferred: Deferred.Deferred<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>
}
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Rule[]
pending: Map<PermissionLegacy.ID, PendingEntry>
approved: PermissionLegacy.Rule[]
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return PermissionV2.evaluate(permission, pattern, ...rulesets)
export function evaluate(permission: string, pattern: string, ...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@ -145,24 +56,18 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, ctx.project.id))
.get()
.pipe(Effect.orDie)
void ctx
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: [...(row?.data ?? [])],
pending: new Map<PermissionLegacy.ID, PendingEntry>(),
approved: [],
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
}
state.pending.clear()
}),
@ -172,7 +77,7 @@ export const layer = Layer.effect(
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
const ask = Effect.fn("Permission.ask")(function* (input: PermissionLegacy.AskInput) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
@ -181,7 +86,7 @@ export const layer = Layer.effect(
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({
return yield* new PermissionLegacy.DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
@ -191,8 +96,8 @@ export const layer = Layer.effect(
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
const id = request.id ?? PermissionLegacy.ID.ascending()
const info: PermissionLegacy.Request = {
id,
sessionID: request.sessionID,
permission: request.permission,
@ -203,7 +108,7 @@ export const layer = Layer.effect(
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const deferred = yield* Deferred.make<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>()
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
@ -214,10 +119,10 @@ export const layer = Layer.effect(
)
})
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
const reply = Effect.fn("Permission.reply")(function* (input: PermissionLegacy.ReplyInput) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
if (!existing) return yield* new PermissionLegacy.NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
@ -229,7 +134,7 @@ export const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
input.message ? new PermissionLegacy.CorrectedError({ feedback: input.message }) : new PermissionLegacy.RejectedError(),
)
for (const [id, item] of pending.entries()) {
@ -240,7 +145,7 @@ export const layer = Layer.effect(
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
}
return
}
@ -290,7 +195,7 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Rule[] = []
const ruleset: PermissionLegacy.Rule[] = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
@ -303,14 +208,21 @@ export function fromConfig(permission: ConfigPermission.Info) {
return ruleset
}
export function merge(...rulesets: Ruleset[]): Rule[] {
return [...PermissionV2.merge(...rulesets)]
export function merge(...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule[] {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return PermissionV2.disabled(tools, ruleset)
export function disabled(tools: string[], ruleset: PermissionLegacy.Ruleset): Set<string> {
const edits = ["edit", "write", "apply_patch"]
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export * as Permission from "."

View File

@ -1,13 +0,0 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { Newtype } from "@opencode-ai/core/schema"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}
}

View File

@ -1,7 +1,7 @@
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
@ -86,10 +86,6 @@ export function fromRow(row: Row): Info {
}
}
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
}
export const UpdateInput = Schema.Struct({
projectID: ProjectV2.ID,
name: Schema.optional(Schema.String),
@ -201,36 +197,6 @@ export const layer = Layer.effect(
.run()
}
const oldPermission = yield* d
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, oldID))
.get()
const newPermission = yield* d
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, newID))
.get()
if (oldPermission && newPermission) {
yield* d
.update(PermissionTable)
.set({
data: mergePermissionRules(oldPermission.data, newPermission.data),
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
time_updated: Date.now(),
})
.where(eq(PermissionTable.project_id, newID))
.run()
yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
}
if (oldPermission && !newPermission) {
yield* d
.update(PermissionTable)
.set({ project_id: newID })
.where(eq(PermissionTable.project_id, oldID))
.run()
}
yield* d
.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })

View File

@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError } from "../errors"
@ -10,7 +10,7 @@ import { described } from "./metadata"
const root = "/permission"
const ReplyPayload = Schema.Struct({
reply: Permission.Reply,
reply: PermissionLegacy.Reply,
message: Schema.optional(Schema.String),
})
@ -20,7 +20,7 @@ export const PermissionApi = HttpApi.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
success: described(Schema.Array(PermissionLegacy.Request), "List of pending permissions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.list",
@ -29,7 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
params: { requestID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),

View File

@ -1,6 +1,6 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
@ -48,7 +48,7 @@ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
metadata: Schema.optional(Session.Metadata),
permission: Schema.optional(Permission.Ruleset),
permission: Schema.optional(PermissionLegacy.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Session.ArchivedTimestamp),
@ -71,7 +71,7 @@ export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInp
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
response: PermissionLegacy.Reply,
})
export const SessionPaths = {
@ -392,7 +392,7 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
params: { sessionID: SessionID, permissionID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),

View File

@ -3,12 +3,16 @@ import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",

View File

@ -1,6 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
@ -34,7 +35,7 @@ export const locationQueryOpenApi = OpenApi.annotations({
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware,
{
provides: Catalog.Service | PluginBoot.Service
provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service
}
>()("@opencode/ExperimentalHttpApiV2Location") {}
@ -59,4 +60,4 @@ export const layer = Layer.effect(
}),
)
}),
).pipe(Layer.provide(LocationServiceMap.layer))
)

View File

@ -0,0 +1,90 @@
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const PermissionGroup = HttpApiGroup.make("v2.permission")
.add(
HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
query: LocationQuery,
success: Schema.Array(PermissionV2.Request),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
.add(
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
params: { sessionID: SessionV2.ID },
success: Schema.Array(PermissionV2.Request),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." }))
.middleware(V2Authorization)
export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
.add(
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Array(PermissionSaved.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
),
)
.add(
HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." }))
.middleware(V2Authorization)

View File

@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@ -14,8 +14,8 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
params: { requestID: PermissionLegacy.ID }
payload: PermissionLegacy.ReplyBody
}) {
yield* svc
.reply({

View File

@ -1,9 +1,9 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Agent } from "@/agent/agent"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@ -360,7 +360,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { sessionID: SessionID; permissionID: PermissionID }
params: { sessionID: SessionID; permissionID: PermissionLegacy.ID }
payload: typeof PermissionResponsePayload.Type
}) {
yield* requireSession(ctx.params.sessionID)

View File

@ -1,12 +1,25 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
export const v2Handlers = Layer.mergeAll(
sessionHandlers,
messageHandlers,
modelHandlers,
providerHandlers,
permissionHandlers,
sessionPermissionHandlers,
savedPermissionHandlers,
).pipe(
Layer.provide(v2LocationLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer),
)

View File

@ -0,0 +1,105 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"permissionRequests",
Effect.fn(function* () {
return yield* (yield* PermissionV2.Service).list()
}),
)
}),
)
export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
const withSessionPermission = Effect.fnUntraced(function* <A, E>(
sessionID: Parameters<PermissionV2.Interface["forSession"]>[0],
use: (permission: PermissionV2.Interface) => Effect.Effect<A, E>,
) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* Effect.gen(function* () {
return yield* use(yield* PermissionV2.Service)
}).pipe(
Effect.scoped,
Effect.provide(
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
),
)
})
return handlers
.handle(
"sessionPermissionRequests",
Effect.fn(function* (ctx) {
return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
permission.forSession(ctx.params.sessionID),
)
}),
)
.handle(
"permissionRequestReply",
Effect.fn(function* (ctx) {
yield* withSessionPermission(ctx.params.sessionID, (permission) =>
Effect.gen(function* () {
const request = yield* permission.get(ctx.params.requestID)
if (!request || request.sessionID !== ctx.params.sessionID)
return yield* missingRequest(ctx.params.requestID)
yield* permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
return handlers
.handle(
"savedPermissions",
Effect.fn(function* (ctx) {
return yield* saved.list({ projectID: ctx.query.projectID })
}),
)
.handle(
"removeSavedPermission",
Effect.fn(function* (ctx) {
yield* saved.remove(ctx.params.id)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Provider } from "@/provider/provider"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@ -15,7 +16,6 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Wildcard } from "@/util/wildcard"
@ -38,7 +38,7 @@ export type StreamInput = {
parentSessionID?: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
system: string[]
messages: ModelMessage[]
small?: boolean
@ -165,7 +165,7 @@ const live: Layer.Layer<
return { approved: true }
}
const id = PermissionID.ascending()
const id = PermissionLegacy.ID.ascending()
let unsub: EventV2.Unsubscribe | undefined
try {
unsub = await bridge.promise(

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Auth } from "@/auth"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { RuntimeFlags } from "@/effect/runtime-flags"
@ -22,7 +23,7 @@ type PrepareInput = {
readonly parentSessionID?: string
readonly model: Provider.Model
readonly agent: Agent.Info
readonly permission?: Permission.Ruleset
readonly permission?: PermissionLegacy.Ruleset
readonly system: string[]
readonly messages: ModelMessage[]
readonly small?: boolean

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Image } from "@/image/image"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
@ -204,7 +205,7 @@ export const layer = Layer.effect(
time: { start: match.part.state.time.start, end: Date.now() },
},
})
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
if (error instanceof PermissionLegacy.RejectedError || error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
yield* settleToolCall(toolCallID)

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import os from "os"
@ -1220,7 +1221,7 @@ export const layer = Layer.effect(
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Rule[] = []
const permissions: PermissionLegacy.Rule[] = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Slug } from "@opencode-ai/core/util/slug"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@ -233,7 +234,7 @@ export const Info = Schema.Struct({
version: Schema.String,
metadata: optionalOmitUndefined(Metadata),
time: Time,
permission: optionalOmitUndefined(Permission.Ruleset),
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(Revert),
}).annotate({ identifier: "Session" })
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@ -258,7 +259,7 @@ export const CreateInput = Schema.optional(
agent: Schema.optional(Schema.String),
model: Schema.optional(Model),
metadata: Schema.optional(Metadata),
permission: Schema.optional(Permission.Ruleset),
permission: Schema.optional(PermissionLegacy.Ruleset),
workspaceID: Schema.optional(WorkspaceV2.ID),
}),
)
@ -282,7 +283,7 @@ export const SetMetadataInput = Schema.Struct({
})
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
})
export const SetRevertInput = Schema.Struct({
sessionID: SessionID,
@ -348,7 +349,7 @@ const UpdatedInfo = Schema.Struct({
version: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(Schema.NullOr(Metadata)),
time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
permission: Schema.optional(Schema.NullOr(PermissionLegacy.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)),
})
@ -472,7 +473,7 @@ export interface Interface {
agent?: string
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
@ -481,7 +482,7 @@ export interface Interface {
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: PermissionLegacy.Ruleset }) => Effect.Effect<void>
readonly setRevert: (input: {
sessionID: SessionID
revert: Info["revert"]
@ -570,7 +571,7 @@ export const layer: Layer.Layer<
directory: string
path?: string
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
}) {
const ctx = yield* InstanceState.context
const result: Info = {
@ -748,7 +749,7 @@ export const layer: Layer.Layer<
agent?: string
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID
}) {
const ctx = yield* InstanceState.context
@ -842,7 +843,7 @@ export const layer: Layer.Layer<
const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID
permission: Permission.Ruleset
permission: PermissionLegacy.Ruleset
}) {
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
Effect.orDie,

View File

@ -3,7 +3,7 @@ import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import path from "path"
import { existsSync } from "fs"
@ -108,13 +108,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// Pre-scan all files upfront to avoid repeated glob operations
log.info("scanning files...")
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, shareFiles] = await Promise.all([
list("project/*.json"),
list("session/*/*.json"),
list("message/*/*.json"),
list("part/*/*.json"),
list("todo/*.json"),
list("permission/*.json"),
list("session_share/*.json"),
])
@ -124,7 +123,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messages: messageFiles.length,
parts: partFiles.length,
todos: todoFiles.length,
permissions: permFiles.length,
shares: shareFiles.length,
})
@ -135,7 +133,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messageFiles.length +
partFiles.length +
todoFiles.length +
permFiles.length +
shareFiles.length,
)
const progress = options?.progress
@ -357,31 +354,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
log.warn("skipped orphaned todos", { count: orphans.todos })
}
// Migrate permissions
const permProjects = permFiles.map((file) => path.basename(file, ".json"))
const permValues: unknown[] = []
for (let i = 0; i < permFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, permFiles.length)
const batch = await read(permFiles, i, end)
permValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const projectID = permProjects[i + j]
if (!projectIds.has(projectID)) {
orphans.permissions++
continue
}
permValues.push({ project_id: projectID, data })
}
stats.permissions += insert(permValues, PermissionTable, "permission")
step("permissions", end - i)
}
log.info("migrated permissions", { count: stats.permissions })
if (orphans.permissions > 0) {
log.warn("skipped orphaned permissions", { count: orphans.permissions })
}
// Migrate session shares
const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
const shareValues: unknown[] = []

View File

@ -1,5 +1,5 @@
export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql"
export { ProjectTable } from "@opencode-ai/core/project/sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
export { SessionShareTable } from "@opencode-ai/core/share/sql"
export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Effect, Schema } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { JSONSchema7 } from "@ai-sdk/provider"
@ -41,7 +42,7 @@ export type Context<M extends Metadata = Metadata> = {
extra?: { [key: string]: unknown }
messages: SessionLegacy.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
ask(input: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {

View File

@ -9,6 +9,7 @@ import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../src/permission"
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
@ -28,7 +29,7 @@ const it = testEffect(agentLayer())
const scout = testEffect(agentLayer({ experimentalScout: true }))
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionLegacy.Action | undefined {
if (!agent) return undefined
return Permission.evaluate(permission, "*", agent.permission).action
}

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
/**
* Reproducer for opencode issue #26514:
*
@ -60,7 +61,7 @@ it.instance("[#26514] subagent spawned from plan mode inherits read-only restric
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
@ -88,7 +89,7 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
@ -113,7 +114,7 @@ it.instance(
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, test, expect } from "bun:test"
import { Effect } from "effect"
import { Permission } from "../src/permission"
@ -9,7 +10,7 @@ const it = testEffect(Config.defaultLayer)
const load = Config.use.get()
describe("Permission.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionLegacy.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@ -75,7 +76,7 @@ describe("Permission.disabled for task tool", () => {
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionLegacy.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { test, expect } from "bun:test"
import os from "os"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
@ -5,7 +6,6 @@ import { EventV2Bridge } from "../../src/event-v2-bridge"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Database } from "@opencode-ai/core/database/database"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
@ -261,8 +261,8 @@ test("merge - preserves rule order", () => {
})
test("merge - config permission overrides default ask", () => {
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const defaults: PermissionLegacy.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
@ -270,8 +270,8 @@ test("merge - config permission overrides default ask", () => {
})
test("merge - config ask overrides default allow", () => {
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const defaults: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
@ -443,8 +443,8 @@ test("evaluate - later wildcard permission can override earlier specific permiss
})
test("evaluate - merges multiple rulesets", () => {
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
@ -588,7 +588,7 @@ it.instance(
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}),
{ git: true },
)
@ -655,10 +655,10 @@ it.instance(
() =>
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const seen = yield* Deferred.make<Permission.Request>()
const seen = yield* Deferred.make<PermissionLegacy.Request>()
const unsub = yield* events.listen((event) => {
if (event.type === Permission.Event.Asked.type)
Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request))
Deferred.doneUnsafe(seen, Effect.succeed(event.data as PermissionLegacy.Request))
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
@ -703,7 +703,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test1"),
id: PermissionLegacy.ID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@ -713,7 +713,7 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test1"), reply: "once" })
yield* Fiber.join(fiber)
}),
{ git: true },
@ -724,7 +724,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2"),
id: PermissionLegacy.ID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@ -734,11 +734,11 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test2"), reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@ -748,7 +748,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2b"),
id: PermissionLegacy.ID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@ -759,7 +759,7 @@ it.instance(
yield* waitForPending(1)
yield* reply({
requestID: PermissionID.make("per_test2b"),
requestID: PermissionLegacy.ID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
@ -768,7 +768,7 @@ it.instance(
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err).toBeInstanceOf(PermissionLegacy.CorrectedError)
expect(String(err)).toContain("Use a safer command")
}
}),
@ -780,7 +780,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test3"),
id: PermissionLegacy.ID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@ -790,7 +790,7 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test3"), reply: "always" })
yield* Fiber.join(fiber)
const result = yield* ask({
@ -811,7 +811,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test4a"),
id: PermissionLegacy.ID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@ -821,7 +821,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test4b"),
id: PermissionLegacy.ID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
patterns: ["foo.ts"],
@ -831,13 +831,13 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test4a"), reply: "reject" })
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isFailure(ea)).toBe(true)
expect(Exit.isFailure(eb)).toBe(true)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@ -847,7 +847,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test5a"),
id: PermissionLegacy.ID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@ -857,7 +857,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test5b"),
id: PermissionLegacy.ID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@ -867,7 +867,7 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test5a"), reply: "always" })
yield* Fiber.join(a)
yield* Fiber.join(b)
@ -881,7 +881,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test6a"),
id: PermissionLegacy.ID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["ls"],
@ -891,7 +891,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test6b"),
id: PermissionLegacy.ID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["ls"],
@ -901,10 +901,10 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test6a"), reply: "always" })
yield* Fiber.join(a)
expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")])
expect((yield* list()).map((item) => item.id)).toEqual([PermissionLegacy.ID.make("per_test6b")])
yield* rejectAll()
yield* Fiber.await(b)
@ -917,10 +917,10 @@ it.instance(
() =>
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>()
const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }>()
const fiber = yield* ask({
id: PermissionID.make("per_test7"),
id: PermissionLegacy.ID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@ -935,13 +935,13 @@ it.instance(
if (event.type === Permission.Event.Replied.type)
Deferred.doneUnsafe(
seen,
Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }),
Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }),
)
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test7"), reply: "once" })
yield* Fiber.join(fiber)
expect(
yield* Deferred.await(seen).pipe(
@ -952,7 +952,7 @@ it.instance(
),
).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
requestID: PermissionLegacy.ID.make("per_test7"),
reply: "once",
})
}),
@ -969,7 +969,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide(
{ directory: one },
ask({
id: PermissionID.make("per_dir_a"),
id: PermissionLegacy.ID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
@ -984,7 +984,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide(
{ directory: two },
ask({
id: PermissionID.make("per_dir_b"),
id: PermissionLegacy.ID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
@ -1000,8 +1000,8 @@ it.live("permission requests stay isolated by directory", () =>
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
expect(onePending[0].id).toBe(PermissionLegacy.ID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionLegacy.ID.make("per_dir_b"))
yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" }))
yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" }))
@ -1018,7 +1018,7 @@ it.instance(
const test = yield* TestInstance
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_dispose"),
id: PermissionLegacy.ID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
@ -1033,7 +1033,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@ -1045,7 +1045,7 @@ it.instance(
const test = yield* TestInstance
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
@ -1059,7 +1059,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@ -1068,7 +1068,7 @@ it.instance(
"reply - fails for unknown requestID",
() =>
Effect.gen(function* () {
const exit = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
const exit = yield* reply({ requestID: PermissionLegacy.ID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
@ -1095,7 +1095,7 @@ it.instance(
],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}),
{ git: true },
)
@ -1135,7 +1135,7 @@ it.instance(
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
expect(yield* list()).toHaveLength(0)
}),
{ git: true },
@ -1149,7 +1149,7 @@ it.instance(
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
@ -1164,7 +1164,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)

View File

@ -8,7 +8,7 @@ import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
@ -218,16 +218,6 @@ describe("Project.fromDirectory", () => {
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id })
@ -245,14 +235,6 @@ describe("Project.fromDirectory", () => {
(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))
?.project_id,
).toBe(remoteID)
expect(
yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, remoteID))
.get()
.pipe(Effect.orDie),
).toBeDefined()
expect(
(yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))
?.project_id,

View File

@ -579,6 +579,32 @@ const scenarios: Scenario[] = [
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, array),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/permission/request/{requestID}/reply", {
sessionID: ctx.state.id,
requestID: "per_httpapi_missing",
}),
headers: ctx.headers(),
body: { reply: "once" },
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
http.protected
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
.status(204, undefined, "status"),
http.protected
.get("/api/session", "v2.session.list")
.at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() }))

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
@ -8,7 +9,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { PermissionID } from "../../src/permission/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
@ -167,7 +167,7 @@ describe("instance HttpApi", () => {
handlerContext,
),
)
const permissionID = PermissionID.ascending()
const permissionID = PermissionLegacy.ID.ascending()
const questionReplyID = QuestionID.ascending()
const questionRejectID = QuestionID.ascending()
const [permission, questionReply, questionReject] = yield* Effect.all(

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterEach, describe, expect } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
@ -11,7 +12,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
@ -913,7 +913,7 @@ describe("session HttpApi", () => {
}),
).toMatchObject({ id: session.id })
const permissionID = String(PermissionID.ascending())
const permissionID = String(PermissionLegacy.ID.ascending())
const permission = yield* request(
pathFor(SessionPaths.permissions, {
sessionID: session.id,

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import path from "path"
@ -332,7 +333,7 @@ describe("session.llm.ai-sdk adapter", () => {
})
test("preserves tool-error cause", async () => {
const error = new Permission.RejectedError()
const error = new PermissionLegacy.RejectedError()
const events = await Effect.runPromise(
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
type: "tool-error",

View File

@ -10,7 +10,7 @@ import { Global } from "@opencode-ai/core/global"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@ -574,7 +574,7 @@ describe("JSON to SQLite migration", () => {
expect(todos[2].position).toBe(2)
})
test("migrates permissions", async () => {
test("does not migrate legacy permissions", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
@ -592,12 +592,7 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(db)
expect(stats?.permissions).toBe(1)
const permissions = db.select().from(PermissionTable).all()
expect(permissions.length).toBe(1)
expect(permissions[0].project_id).toBe("proj_test123abc")
expect(permissions[0].data).toEqual(permissionData)
expect(stats?.permissions).toBe(0)
})
test("migrates session shares", async () => {
@ -694,7 +689,7 @@ describe("JSON to SQLite migration", () => {
expect(todos[1].position).toBe(2)
})
test("skips orphaned todos, permissions, and shares", async () => {
test("skips orphaned todos and shares", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
@ -733,11 +728,10 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.permissions).toBe(0)
expect(stats.shares).toBe(1)
expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1)
})
@ -848,7 +842,7 @@ describe("JSON to SQLite migration", () => {
expect(stats.messages).toBe(1)
expect(stats.parts).toBe(1)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.permissions).toBe(0)
expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
@ -857,7 +851,6 @@ describe("JSON to SQLite migration", () => {
expect(db.select().from(MessageTable).all().length).toBe(1)
expect(db.select().from(PartTable).all().length).toBe(1)
expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1)
})
})

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
@ -26,7 +27,7 @@ const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
function makeCtx() {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) =>

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
@ -52,12 +53,12 @@ const ctx = {
}
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import os from "os"
@ -186,7 +187,7 @@ describe("tool.grep", () => {
[path.join(alias, "*")]: "allow",
},
})
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = {
...ctx,
ask: (req) =>
@ -234,7 +235,7 @@ describe("tool.grep", () => {
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = {
...ctx,
ask: (req) =>

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
@ -83,12 +84,12 @@ const put = Effect.fn("LspToolTest.put")(function* (file: string) {
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import path from "path"
@ -140,12 +141,12 @@ const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
return yield* fs.readFileString(p)
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
@ -328,7 +329,7 @@ describe("tool.read env file permissions", () => {
let asked = false
const next = {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, info.permission)
@ -336,7 +337,7 @@ describe("tool.read env file permissions", () => {
asked = true
}
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: info.permission })
throw new PermissionLegacy.DeniedError({ ruleset: info.permission })
}
}
}),

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
@ -155,9 +156,9 @@ const each = (
}
}
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
const capture = (requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
requests.push(req)
if (stop) throw stop
@ -222,7 +223,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "echo hello",
@ -244,7 +245,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "echo foo && echo bar",
@ -268,7 +269,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Write-Host foo; if ($?) { Write-Host bar }",
@ -297,7 +298,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -323,7 +324,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
expect(
@ -354,7 +355,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: `echo $(cat "${file}")`,
@ -383,7 +384,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -409,7 +410,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
yield* run(
{
@ -440,7 +441,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -468,7 +469,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -497,7 +498,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -525,7 +526,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -560,7 +561,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
expect(
yield* fail(
@ -593,7 +594,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Get-Content $env:WINDIR/win.ini",
@ -620,7 +621,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -649,7 +650,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -677,7 +678,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Set-Location C:/Windows",
@ -705,7 +706,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Write-Output ('a' * 3)",
@ -731,7 +732,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
@ -755,7 +756,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -779,7 +780,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -810,7 +811,7 @@ describe("tool.shell permissions", () => {
const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*"))
for (const dir of forms(outerTmp)) {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{
@ -842,7 +843,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*"))
expect(
yield* fail(
@ -871,7 +872,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*"))
expect(
yield* fail(
@ -903,7 +904,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const filepath = path.join(outerTmp, "outside.txt")
expect(
yield* fail(
@ -931,7 +932,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: `rm -rf ${path.join(tmp, "nested")}`,
@ -952,7 +953,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "git log --oneline -5",
@ -974,7 +975,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "cd .",
@ -996,7 +997,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* fail(
{ command: "echo test > output.txt", description: "Redirect test output" },
@ -1017,7 +1018,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run({ command: "ls -la", description: "List" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()

View File

@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test"
@ -67,7 +68,7 @@ Use this skill.
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) =>

View File

@ -117,6 +117,7 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PermissionV2Reply,
ProjectCurrentErrors,
ProjectCurrentResponses,
ProjectInitGitErrors,
@ -248,6 +249,12 @@ import type {
TuiSubmitPromptResponses,
V2ModelListErrors,
V2ModelListResponses,
V2PermissionRequestListErrors,
V2PermissionRequestListResponses,
V2PermissionSavedListErrors,
V2PermissionSavedListResponses,
V2PermissionSavedRemoveErrors,
V2PermissionSavedRemoveResponses,
V2ProviderGetErrors,
V2ProviderGetResponses,
V2ProviderListErrors,
@ -260,6 +267,10 @@ import type {
V2SessionListResponses,
V2SessionMessagesErrors,
V2SessionMessagesResponses,
V2SessionPermissionListErrors,
V2SessionPermissionListResponses,
V2SessionPermissionReplyErrors,
V2SessionPermissionReplyResponses,
V2SessionPromptErrors,
V2SessionPromptResponses,
V2SessionWaitErrors,
@ -4255,6 +4266,74 @@ export class Sync extends HeyApiClient {
}
}
export class Permission2 extends HeyApiClient {
/**
* List session permission requests
*
* Retrieve pending permission requests owned by a session.
*/
public list<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).get<
V2SessionPermissionListResponses,
V2SessionPermissionListErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/permission/request",
...options,
...params,
})
}
/**
* Reply to pending permission request
*
* Respond to a pending permission request owned by a session.
*/
public reply<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
requestID: string
reply?: PermissionV2Reply
message?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "path", key: "requestID" },
{ in: "body", key: "reply" },
{ in: "body", key: "message" },
],
},
],
)
return (options?.client ?? this.client).post<
V2SessionPermissionReplyResponses,
V2SessionPermissionReplyErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/permission/request/{requestID}/reply",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Session3 extends HeyApiClient {
/**
* List v2 sessions
@ -4474,6 +4553,11 @@ export class Session3 extends HeyApiClient {
...params,
})
}
private _permission?: Permission2
get permission(): Permission2 {
return (this._permission ??= new Permission2({ client: this.client }))
}
}
export class Model extends HeyApiClient {
@ -4557,6 +4641,94 @@ export class Provider2 extends HeyApiClient {
}
}
export class Request extends HeyApiClient {
/**
* List pending permission requests
*
* Retrieve pending permission requests for a location.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<
V2PermissionRequestListResponses,
V2PermissionRequestListErrors,
ThrowOnError
>({
url: "/api/permission/request",
...options,
...params,
})
}
}
export class Saved extends HeyApiClient {
/**
* List saved permissions
*
* Retrieve saved permissions, optionally filtered by project.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
projectID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }])
return (options?.client ?? this.client).get<
V2PermissionSavedListResponses,
V2PermissionSavedListErrors,
ThrowOnError
>({
url: "/api/permission/saved",
...options,
...params,
})
}
/**
* Remove saved permission
*
* Remove a saved permission by ID.
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
id: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }])
return (options?.client ?? this.client).delete<
V2PermissionSavedRemoveResponses,
V2PermissionSavedRemoveErrors,
ThrowOnError
>({
url: "/api/permission/saved/{id}",
...options,
...params,
})
}
}
export class Permission3 extends HeyApiClient {
private _request?: Request
get request(): Request {
return (this._request ??= new Request({ client: this.client }))
}
private _saved?: Saved
get saved(): Saved {
return (this._saved ??= new Saved({ client: this.client }))
}
}
export class V2 extends HeyApiClient {
private _session?: Session3
get session(): Session3 {
@ -4572,6 +4744,11 @@ export class V2 extends HeyApiClient {
get provider(): Provider2 {
return (this._provider ??= new Provider2({ client: this.client }))
}
private _permission?: Permission3
get permission(): Permission3 {
return (this._permission ??= new Permission3({ client: this.client }))
}
}
export class Control extends HeyApiClient {

View File

@ -44,10 +44,10 @@ export type Event =
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventPermissionAsked
| EventPermissionReplied
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
@ -78,6 +78,8 @@ export type Event =
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventAccountAdded
| EventAccountRemoved
| EventAccountSwitched
@ -145,6 +147,16 @@ export type SnapshotFileDiff = {
status?: "added" | "deleted" | "modified"
}
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRule = {
permission: string
pattern: string
action: PermissionAction
}
export type PermissionRuleset = Array<PermissionRule>
export type Session = {
id: string
slug: string
@ -1094,6 +1106,29 @@ export type GlobalEvent = {
delta: string
}
}
| {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
| {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
| {
id: string
type: "permission.asked"
@ -1121,29 +1156,6 @@ export type GlobalEvent = {
reply: "once" | "always" | "reject"
}
}
| {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
| {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
| {
id: string
type: "question.asked"
@ -1415,6 +1427,30 @@ export type GlobalEvent = {
[key: string]: unknown
}
}
| {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
| {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
| {
id: string
type: "account.added"
@ -2029,16 +2065,6 @@ export type WorktreeResetInput = {
directory: string
}
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRule = {
permission: string
pattern: string
action: PermissionAction
}
export type PermissionRuleset = Array<PermissionRule>
export type ProjectSummary = {
id: string
name?: string
@ -2811,6 +2837,14 @@ export type SessionNextRetryError = {
}
}
export type PermissionV2Source = {
type: "tool"
messageID: string
callID: string
}
export type PermissionV2Reply = "once" | "always" | "reject"
export type AuthOAuthCredential = {
type: "oauth"
refresh: string
@ -3637,6 +3671,25 @@ export type ProviderV2Info = {
}
}
export type PermissionV2Request = {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
export type PermissionSavedInfo = {
id: string
projectID: string
action: string
resource: string
}
export type EventModelsDevRefreshed = {
id: string
type: "models-dev.refreshed"
@ -4173,6 +4226,31 @@ export type EventMessagePartDelta = {
}
}
export type EventSessionDiff = {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
export type EventSessionError = {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
export type EventPermissionAsked = {
id: string
type: "permission.asked"
@ -4202,31 +4280,6 @@ export type EventPermissionReplied = {
}
}
export type EventSessionDiff = {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
export type EventSessionError = {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
export type EventQuestionAsked = {
id: string
type: "question.asked"
@ -4473,6 +4526,32 @@ export type EventGlobalDisposed = {
}
}
export type EventPermissionV2Asked = {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
export type EventPermissionV2Replied = {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
export type EventAccountAdded = {
id: string
type: "account.added"
@ -8262,6 +8341,177 @@ export type V2ProviderGetResponses = {
export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses]
export type V2PermissionRequestListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/permission/request"
}
export type V2PermissionRequestListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors]
export type V2PermissionRequestListResponses = {
/**
* Success
*/
200: Array<PermissionV2Request>
}
export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses]
export type V2SessionPermissionListData = {
body?: never
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/permission/request"
}
export type V2SessionPermissionListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors]
export type V2SessionPermissionListResponses = {
/**
* Success
*/
200: Array<PermissionV2Request>
}
export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses]
export type V2SessionPermissionReplyData = {
body?: {
reply: PermissionV2Reply
message?: string
}
path: {
sessionID: string
requestID: string
}
query?: never
url: "/api/session/{sessionID}/permission/request/{requestID}/reply"
}
export type V2SessionPermissionReplyErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError | PermissionNotFoundError
*/
404: SessionNotFoundError | PermissionNotFoundError
}
export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors]
export type V2SessionPermissionReplyResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionPermissionReplyResponse =
V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses]
export type V2PermissionSavedListData = {
body?: never
path?: never
query?: {
projectID?: string
}
url: "/api/permission/saved"
}
export type V2PermissionSavedListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors]
export type V2PermissionSavedListResponses = {
/**
* Success
*/
200: Array<PermissionSavedInfo>
}
export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses]
export type V2PermissionSavedRemoveData = {
body?: never
path: {
id: string
}
query?: never
url: "/api/permission/saved/{id}"
}
export type V2PermissionSavedRemoveErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors]
export type V2PermissionSavedRemoveResponses = {
/**
* <No Content>
*/
204: void
}
export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses]
export type TuiAppendPromptData = {
body?: {
text: string