mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-25 18:14:58 +00:00
refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511)
This commit is contained in:
@@ -159,7 +159,7 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
for (const pattern of req.patterns) {
|
for (const pattern of req.patterns) {
|
||||||
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||||
if (rule.action === "deny") {
|
if (rule.action === "deny") {
|
||||||
throw new PermissionNext.DeniedError(ruleset)
|
throw new PermissionNext.DeniedError({ ruleset })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { AccountService } from "@/account/service"
|
import { AccountService } from "@/account/service"
|
||||||
import { AuthService } from "@/auth/service"
|
import { AuthService } from "@/auth/service"
|
||||||
|
import { PermissionService } from "@/permission/service"
|
||||||
import { QuestionService } from "@/question/service"
|
import { QuestionService } from "@/question/service"
|
||||||
|
|
||||||
export const runtime = ManagedRuntime.make(
|
export const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer),
|
Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export namespace Permission {
|
|||||||
result.push(item.info)
|
result.push(item.info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result.sort((a, b) => a.id.localeCompare(b.id))
|
return result.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { Bus } from "@/bus"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
|
||||||
import { Wildcard } from "@/util/wildcard"
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Effect } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import z from "zod"
|
import * as S from "./service"
|
||||||
|
import type {
|
||||||
|
Action as ActionType,
|
||||||
|
PermissionError,
|
||||||
|
Reply as ReplyType,
|
||||||
|
Request as RequestType,
|
||||||
|
Rule as RuleType,
|
||||||
|
Ruleset as RulesetType,
|
||||||
|
} from "./service"
|
||||||
|
|
||||||
export namespace PermissionNext {
|
export namespace PermissionNext {
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
function expand(pattern: string): string {
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
if (pattern === "~") return os.homedir()
|
if (pattern === "~") return os.homedir()
|
||||||
@@ -24,26 +23,26 @@ export namespace PermissionNext {
|
|||||||
return pattern
|
return pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
function runPromise<A>(f: (service: S.PermissionService.Api) => Effect.Effect<A, PermissionError>) {
|
||||||
ref: "PermissionAction",
|
return runtime.runPromise(S.PermissionService.use(f))
|
||||||
})
|
}
|
||||||
export type Action = z.infer<typeof Action>
|
|
||||||
|
|
||||||
export const Rule = z
|
export const Action = S.Action
|
||||||
.object({
|
export type Action = ActionType
|
||||||
permission: z.string(),
|
export const Rule = S.Rule
|
||||||
pattern: z.string(),
|
export type Rule = RuleType
|
||||||
action: Action,
|
export const Ruleset = S.Ruleset
|
||||||
})
|
export type Ruleset = RulesetType
|
||||||
.meta({
|
export const Request = S.Request
|
||||||
ref: "PermissionRule",
|
export type Request = RequestType
|
||||||
})
|
export const Reply = S.Reply
|
||||||
export type Rule = z.infer<typeof Rule>
|
export type Reply = ReplyType
|
||||||
|
export const Approval = S.Approval
|
||||||
export const Ruleset = Rule.array().meta({
|
export const Event = S.Event
|
||||||
ref: "PermissionRuleset",
|
export const Service = S.PermissionService
|
||||||
})
|
export const RejectedError = S.RejectedError
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
export const CorrectedError = S.CorrectedError
|
||||||
|
export const DeniedError = S.DeniedError
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
export function fromConfig(permission: Config.Permission) {
|
||||||
const ruleset: Ruleset = []
|
const ruleset: Ruleset = []
|
||||||
@@ -67,178 +66,16 @@ export namespace PermissionNext {
|
|||||||
return rulesets.flat()
|
return rulesets.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Request = z
|
export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input)))
|
||||||
.object({
|
|
||||||
id: PermissionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
permission: z.string(),
|
|
||||||
patterns: z.string().array(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
always: z.string().array(),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Request = z.infer<typeof Request>
|
export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input)))
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
export async function list() {
|
||||||
export type Reply = z.infer<typeof Reply>
|
return runPromise((service) => service.list())
|
||||||
|
|
||||||
export const Approval = z.object({
|
|
||||||
projectID: ProjectID.zod,
|
|
||||||
patterns: z.string().array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
resolve: () => void
|
|
||||||
reject: (e: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = Instance.state(() => {
|
|
||||||
const projectID = Instance.project.id
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
|
|
||||||
)
|
|
||||||
const stored = row?.data ?? ([] as Ruleset)
|
|
||||||
|
|
||||||
return {
|
|
||||||
pending: new Map<PermissionID, PendingEntry>(),
|
|
||||||
approved: stored,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ask = fn(
|
|
||||||
Request.partial({ id: true }).extend({
|
|
||||||
ruleset: Ruleset,
|
|
||||||
}),
|
|
||||||
async (input) => {
|
|
||||||
const s = await state()
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
for (const pattern of request.patterns ?? []) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny")
|
|
||||||
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
|
|
||||||
if (rule.action === "ask") {
|
|
||||||
const id = input.id ?? PermissionID.ascending()
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
}
|
|
||||||
s.pending.set(id, {
|
|
||||||
info,
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
})
|
|
||||||
Bus.publish(Event.Asked, info)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const reply = fn(
|
|
||||||
z.object({
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
message: z.string().optional(),
|
|
||||||
}),
|
|
||||||
async (input) => {
|
|
||||||
const s = await state()
|
|
||||||
const existing = s.pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
s.pending.delete(input.requestID)
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError())
|
|
||||||
// Reject all other pending permissions for this session
|
|
||||||
const sessionID = existing.info.sessionID
|
|
||||||
for (const [id, pending] of s.pending) {
|
|
||||||
if (pending.info.sessionID === sessionID) {
|
|
||||||
s.pending.delete(id)
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: pending.info.sessionID,
|
|
||||||
requestID: pending.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
pending.reject(new RejectedError())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (input.reply === "once") {
|
|
||||||
existing.resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (input.reply === "always") {
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
s.approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.resolve()
|
|
||||||
|
|
||||||
const sessionID = existing.info.sessionID
|
|
||||||
for (const [id, pending] of s.pending) {
|
|
||||||
if (pending.info.sessionID !== sessionID) continue
|
|
||||||
const ok = pending.info.patterns.every(
|
|
||||||
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
s.pending.delete(id)
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: pending.info.sessionID,
|
|
||||||
requestID: pending.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
pending.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: we don't save the permission ruleset to disk yet until there's
|
|
||||||
// UI to manage it
|
|
||||||
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
|
|
||||||
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
const merged = merge(...rulesets)
|
return S.evaluate(permission, pattern, ...rulesets)
|
||||||
log.info("evaluate", { permission, pattern, ruleset: merged })
|
|
||||||
const match = merged.findLast(
|
|
||||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
|
||||||
)
|
|
||||||
return match ?? { action: "ask", permission, pattern: "*" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
||||||
@@ -247,39 +84,10 @@ export namespace PermissionNext {
|
|||||||
const result = new Set<string>()
|
const result = new Set<string>()
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
|
|
||||||
if (!rule) continue
|
if (!rule) continue
|
||||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User rejected without message - halts execution */
|
|
||||||
export class RejectedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super(`The user rejected permission to use this specific tool call.`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User rejected with message - continues with guidance */
|
|
||||||
export class CorrectedError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auto-rejected by config rule - halts execution */
|
|
||||||
export class DeniedError extends Error {
|
|
||||||
constructor(public readonly ruleset: Ruleset) {
|
|
||||||
super(
|
|
||||||
`The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
const s = await state()
|
|
||||||
return Array.from(s.pending.values(), (x) => x.info)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { withStatics } from "@/util/schema"
|
import { Newtype } from "@/util/schema"
|
||||||
|
|
||||||
const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID"))
|
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
|
||||||
|
static make(id: string): PermissionID {
|
||||||
|
return this.makeUnsafe(id)
|
||||||
|
}
|
||||||
|
|
||||||
export type PermissionID = typeof permissionIdSchema.Type
|
static ascending(id?: string): PermissionID {
|
||||||
|
return this.makeUnsafe(Identifier.ascending("permission", id))
|
||||||
|
}
|
||||||
|
|
||||||
export const PermissionID = permissionIdSchema.pipe(
|
static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>
|
||||||
withStatics((schema: typeof permissionIdSchema) => ({
|
}
|
||||||
make: (id: string) => schema.makeUnsafe(id),
|
|
||||||
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)),
|
|
||||||
zod: Identifier.schema("permission").pipe(z.custom<PermissionID>()),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|||||||
265
packages/opencode/src/permission/service.ts
Normal file
265
packages/opencode/src/permission/service.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { InstanceState } from "@/util/instance-state"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
|
export const Rule = z
|
||||||
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
|
})
|
||||||
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
|
export const Ruleset = Rule.array().meta({
|
||||||
|
ref: "PermissionRuleset",
|
||||||
|
})
|
||||||
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: PermissionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
permission: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRequest",
|
||||||
|
})
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Approval = z.object({
|
||||||
|
projectID: ProjectID.zod,
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionError = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
pending: Map<PermissionID, PendingEntry>
|
||||||
|
approved: Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AskInput = Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReplyInput = z.object({
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace PermissionService {
|
||||||
|
export interface Api {
|
||||||
|
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
|
||||||
|
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
|
||||||
|
"@opencode/PermissionNext",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
PermissionService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instanceState = yield* InstanceState.make<State>(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
pending: new Map<PermissionID, PendingEntry>(),
|
||||||
|
approved: row?.data ?? [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
|
const state = yield* InstanceState.get(instanceState)
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let pending = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, state.approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
pending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
state.pending.set(id, { info, deferred })
|
||||||
|
void Bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
state.pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
|
const state = yield* InstanceState.get(instanceState)
|
||||||
|
const existing = state.pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
state.pending.delete(input.requestID)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of state.pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
state.pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
state.approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of state.pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, state.approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
state.pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we don't save the permission ruleset to disk yet until there's
|
||||||
|
// UI to manage it
|
||||||
|
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
|
||||||
|
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("PermissionService.list")(function* () {
|
||||||
|
const state = yield* InstanceState.get(instanceState)
|
||||||
|
return Array.from(state.pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return PermissionService.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
const merged = rulesets.flat()
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: merged })
|
||||||
|
const match = merged.findLast(
|
||||||
|
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||||
|
)
|
||||||
|
return match ?? { action: "ask", permission, pattern: "*" }
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import * as S from "./service"
|
|||||||
import type { QuestionID } from "./schema"
|
import type { QuestionID } from "./schema"
|
||||||
import type { SessionID, MessageID } from "@/session/schema"
|
import type { SessionID, MessageID } from "@/session/schema"
|
||||||
|
|
||||||
function runPromise<A>(f: (service: S.QuestionService.Service) => Effect.Effect<A, S.QuestionServiceError>) {
|
function runPromise<A, E>(f: (service: S.QuestionService.Service) => Effect.Effect<A, E>) {
|
||||||
return runtime.runPromise(S.QuestionService.use(f))
|
return runtime.runPromise(S.QuestionService.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,22 +72,17 @@ export const Event = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RejectedError extends Error {
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||||
constructor() {
|
override get message() {
|
||||||
super("The user dismissed this question")
|
return "The user dismissed this question"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Effect service ---
|
// --- Effect service ---
|
||||||
|
|
||||||
export class QuestionServiceError extends Schema.TaggedErrorClass<QuestionServiceError>()("QuestionServiceError", {
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
interface PendingEntry {
|
||||||
info: Request
|
info: Request
|
||||||
deferred: Deferred.Deferred<Answer[]>
|
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace QuestionService {
|
export namespace QuestionService {
|
||||||
@@ -96,10 +91,10 @@ export namespace QuestionService {
|
|||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}) => Effect.Effect<Answer[], QuestionServiceError>
|
}) => Effect.Effect<Answer[], RejectedError>
|
||||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void, QuestionServiceError>
|
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void, QuestionServiceError>
|
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Request[], QuestionServiceError>
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +104,7 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
|||||||
static readonly layer = Layer.effect(
|
static readonly layer = Layer.effect(
|
||||||
QuestionService,
|
QuestionService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>, QuestionServiceError>(() =>
|
const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>>(() =>
|
||||||
Effect.succeed(new Map<QuestionID, PendingEntry>()),
|
Effect.succeed(new Map<QuestionID, PendingEntry>()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,7 +119,7 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
|||||||
const id = QuestionID.ascending()
|
const id = QuestionID.ascending()
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<Answer[]>()
|
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||||
const info: Request = {
|
const info: Request = {
|
||||||
id,
|
id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -167,7 +162,7 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
|||||||
sessionID: existing.info.sessionID,
|
sessionID: existing.info.sessionID,
|
||||||
requestID: existing.info.id,
|
requestID: existing.info.id,
|
||||||
})
|
})
|
||||||
yield* Deferred.die(existing.deferred, new RejectedError())
|
yield* Deferred.fail(existing.deferred, new RejectedError)
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("QuestionService.list")(function* () {
|
const list = Effect.fn("QuestionService.list")(function* () {
|
||||||
|
|||||||
@@ -43,14 +43,16 @@ export namespace InstanceState {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** Get the cached value for the current directory, initializing it if needed. */
|
/** Get the cached value for the current directory, initializing it if needed. */
|
||||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
|
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
|
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||||
|
|
||||||
/** Check whether a value exists for the current directory. */
|
/** Check whether a value exists for the current directory. */
|
||||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
|
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
|
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||||
|
|
||||||
/** Invalidate the cached value for the current directory. */
|
/** Invalidate the cached value for the current directory. */
|
||||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
ScopedCache.invalidate(self.cache, Instance.directory)
|
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||||
|
|
||||||
/** Invalidate the given directory across all InstanceState caches. */
|
/** Invalidate the given directory across all InstanceState caches. */
|
||||||
export const dispose = (directory: string) =>
|
export const dispose = (directory: string) =>
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { test, expect } from "bun:test"
|
import { test, expect } from "bun:test"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
|
import { runtime } from "../../src/effect/runtime"
|
||||||
import { PermissionNext } from "../../src/permission/next"
|
import { PermissionNext } from "../../src/permission/next"
|
||||||
|
import * as S from "../../src/permission/service"
|
||||||
import { PermissionID } from "../../src/permission/schema"
|
import { PermissionID } from "../../src/permission/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { MessageID, SessionID } from "../../src/session/schema"
|
||||||
|
|
||||||
|
async function rejectAll(message?: string) {
|
||||||
|
for (const req of await PermissionNext.list()) {
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: req.id,
|
||||||
|
reply: "reject",
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPending(count: number) {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const list = await PermissionNext.list()
|
||||||
|
if (list.length === count) return list
|
||||||
|
await Bun.sleep(0)
|
||||||
|
}
|
||||||
|
return PermissionNext.list()
|
||||||
|
}
|
||||||
|
|
||||||
// fromConfig tests
|
// fromConfig tests
|
||||||
|
|
||||||
@@ -511,6 +533,84 @@ test("ask - returns pending promise when action is ask", async () => {
|
|||||||
// Promise should be pending, not resolved
|
// Promise should be pending, not resolved
|
||||||
expect(promise).toBeInstanceOf(Promise)
|
expect(promise).toBeInstanceOf(Promise)
|
||||||
// Don't await - just verify it returns a promise
|
// Don't await - just verify it returns a promise
|
||||||
|
await rejectAll()
|
||||||
|
await promise.catch(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ask - adds request to pending list", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const ask = PermissionNext.ask({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: { cmd: "ls" },
|
||||||
|
always: ["ls"],
|
||||||
|
tool: {
|
||||||
|
messageID: MessageID.make("msg_test"),
|
||||||
|
callID: "call_test",
|
||||||
|
},
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await PermissionNext.list()
|
||||||
|
expect(list).toHaveLength(1)
|
||||||
|
expect(list[0]).toMatchObject({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: { cmd: "ls" },
|
||||||
|
always: ["ls"],
|
||||||
|
tool: {
|
||||||
|
messageID: MessageID.make("msg_test"),
|
||||||
|
callID: "call_test",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await rejectAll()
|
||||||
|
await ask.catch(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ask - publishes asked event", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
let seen: PermissionNext.Request | undefined
|
||||||
|
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
|
||||||
|
seen = event.properties
|
||||||
|
})
|
||||||
|
|
||||||
|
const ask = PermissionNext.ask({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: { cmd: "ls" },
|
||||||
|
always: ["ls"],
|
||||||
|
tool: {
|
||||||
|
messageID: MessageID.make("msg_test"),
|
||||||
|
callID: "call_test",
|
||||||
|
},
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await PermissionNext.list()).toHaveLength(1)
|
||||||
|
expect(seen).toBeDefined()
|
||||||
|
expect(seen).toMatchObject({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
})
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
await rejectAll()
|
||||||
|
await ask.catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -532,6 +632,8 @@ test("reply - once resolves the pending ask", async () => {
|
|||||||
ruleset: [],
|
ruleset: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await waitForPending(1)
|
||||||
|
|
||||||
await PermissionNext.reply({
|
await PermissionNext.reply({
|
||||||
requestID: PermissionID.make("per_test1"),
|
requestID: PermissionID.make("per_test1"),
|
||||||
reply: "once",
|
reply: "once",
|
||||||
@@ -557,6 +659,8 @@ test("reply - reject throws RejectedError", async () => {
|
|||||||
ruleset: [],
|
ruleset: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await waitForPending(1)
|
||||||
|
|
||||||
await PermissionNext.reply({
|
await PermissionNext.reply({
|
||||||
requestID: PermissionID.make("per_test2"),
|
requestID: PermissionID.make("per_test2"),
|
||||||
reply: "reject",
|
reply: "reject",
|
||||||
@@ -567,6 +671,36 @@ test("reply - reject throws RejectedError", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reply - reject with message throws CorrectedError", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const ask = PermissionNext.ask({
|
||||||
|
id: PermissionID.make("per_test2b"),
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitForPending(1)
|
||||||
|
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: PermissionID.make("per_test2b"),
|
||||||
|
reply: "reject",
|
||||||
|
message: "Use a safer command",
|
||||||
|
})
|
||||||
|
|
||||||
|
const err = await ask.catch((err) => err)
|
||||||
|
expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
|
||||||
|
expect(err.message).toContain("Use a safer command")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("reply - always persists approval and resolves", async () => {
|
test("reply - always persists approval and resolves", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -582,6 +716,8 @@ test("reply - always persists approval and resolves", async () => {
|
|||||||
ruleset: [],
|
ruleset: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await waitForPending(1)
|
||||||
|
|
||||||
await PermissionNext.reply({
|
await PermissionNext.reply({
|
||||||
requestID: PermissionID.make("per_test3"),
|
requestID: PermissionID.make("per_test3"),
|
||||||
reply: "always",
|
reply: "always",
|
||||||
@@ -633,6 +769,8 @@ test("reply - reject cancels all pending for same session", async () => {
|
|||||||
ruleset: [],
|
ruleset: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await waitForPending(2)
|
||||||
|
|
||||||
// Catch rejections before they become unhandled
|
// Catch rejections before they become unhandled
|
||||||
const result1 = askPromise1.catch((e) => e)
|
const result1 = askPromise1.catch((e) => e)
|
||||||
const result2 = askPromise2.catch((e) => e)
|
const result2 = askPromise2.catch((e) => e)
|
||||||
@@ -650,6 +788,144 @@ test("reply - reject cancels all pending for same session", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reply - always resolves matching pending requests in same session", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const a = PermissionNext.ask({
|
||||||
|
id: PermissionID.make("per_test5a"),
|
||||||
|
sessionID: SessionID.make("session_same"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: ["ls"],
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const b = PermissionNext.ask({
|
||||||
|
id: PermissionID.make("per_test5b"),
|
||||||
|
sessionID: SessionID.make("session_same"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitForPending(2)
|
||||||
|
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: PermissionID.make("per_test5a"),
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(a).resolves.toBeUndefined()
|
||||||
|
await expect(b).resolves.toBeUndefined()
|
||||||
|
expect(await PermissionNext.list()).toHaveLength(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reply - always keeps other session pending", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const a = PermissionNext.ask({
|
||||||
|
id: PermissionID.make("per_test6a"),
|
||||||
|
sessionID: SessionID.make("session_a"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: ["ls"],
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const b = PermissionNext.ask({
|
||||||
|
id: PermissionID.make("per_test6b"),
|
||||||
|
sessionID: SessionID.make("session_b"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitForPending(2)
|
||||||
|
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: PermissionID.make("per_test6a"),
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(a).resolves.toBeUndefined()
|
||||||
|
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
|
||||||
|
|
||||||
|
await rejectAll()
|
||||||
|
await b.catch(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reply - publishes replied event", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const ask = PermissionNext.ask({
|
||||||
|
id: PermissionID.make("per_test7"),
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
ruleset: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitForPending(1)
|
||||||
|
|
||||||
|
let seen:
|
||||||
|
| {
|
||||||
|
sessionID: SessionID
|
||||||
|
requestID: PermissionID
|
||||||
|
reply: PermissionNext.Reply
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
|
||||||
|
seen = event.properties
|
||||||
|
})
|
||||||
|
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: PermissionID.make("per_test7"),
|
||||||
|
reply: "once",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(ask).resolves.toBeUndefined()
|
||||||
|
expect(seen).toEqual({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
requestID: PermissionID.make("per_test7"),
|
||||||
|
reply: "once",
|
||||||
|
})
|
||||||
|
unsub()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reply - does nothing for unknown requestID", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: PermissionID.make("per_unknown"),
|
||||||
|
reply: "once",
|
||||||
|
})
|
||||||
|
expect(await PermissionNext.list()).toHaveLength(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("ask - checks all patterns and stops on first deny", async () => {
|
test("ask - checks all patterns and stops on first deny", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -689,3 +965,74 @@ test("ask - allows all patterns when all match allow rules", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const ask = PermissionNext.ask({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["echo hello", "rm -rf /"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
ruleset: [
|
||||||
|
{ permission: "bash", pattern: "echo *", action: "ask" },
|
||||||
|
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const out = await Promise.race([
|
||||||
|
ask.then(
|
||||||
|
() => ({ ok: true as const, err: undefined }),
|
||||||
|
(err) => ({ ok: false as const, err }),
|
||||||
|
),
|
||||||
|
Bun.sleep(100).then(() => "timeout" as const),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (out === "timeout") {
|
||||||
|
await rejectAll()
|
||||||
|
await ask.catch(() => {})
|
||||||
|
throw new Error("ask timed out instead of denying immediately")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(out.ok).toBe(false)
|
||||||
|
expect(out.err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||||
|
expect(await PermissionNext.list()).toHaveLength(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ask - abort should clear pending request", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const ctl = new AbortController()
|
||||||
|
const ask = runtime.runPromise(
|
||||||
|
S.PermissionService.use((svc) =>
|
||||||
|
svc.ask({
|
||||||
|
sessionID: SessionID.make("session_test"),
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["ls"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ signal: ctl.signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitForPending(1)
|
||||||
|
ctl.abort()
|
||||||
|
await ask.catch(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await PermissionNext.list()).toHaveLength(0)
|
||||||
|
} finally {
|
||||||
|
await rejectAll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe("tool.read env file permissions", () => {
|
|||||||
askedForEnv = true
|
askedForEnv = true
|
||||||
}
|
}
|
||||||
if (rule.action === "deny") {
|
if (rule.action === "deny") {
|
||||||
throw new PermissionNext.DeniedError(agent.permission)
|
throw new PermissionNext.DeniedError({ ruleset: agent.permission })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||||
|
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceState } from "../../src/util/instance-state"
|
import { InstanceState } from "../../src/util/instance-state"
|
||||||
@@ -114,6 +114,129 @@ test("InstanceState is disposed on disposeAll", async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("InstanceState.get reads correct directory per-evaluation (not captured once)", async () => {
|
||||||
|
await using a = await tmpdir()
|
||||||
|
await using b = await tmpdir()
|
||||||
|
|
||||||
|
// Regression: InstanceState.get must be lazy (Effect.suspend) so the
|
||||||
|
// directory is read per-evaluation, not captured once at the call site.
|
||||||
|
// Without this, a service built inside a ManagedRuntime Layer would
|
||||||
|
// freeze to whichever directory triggered the first layer build.
|
||||||
|
|
||||||
|
interface TestApi {
|
||||||
|
readonly getDir: () => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestService extends ServiceMap.Service<TestService, TestApi>()("@test/ALS-lazy") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
TestService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((dir) => Effect.sync(() => dir))
|
||||||
|
// `get` is created once during layer build — must be lazy
|
||||||
|
const get = InstanceState.get(state)
|
||||||
|
|
||||||
|
const getDir = Effect.fn("TestService.getDir")(function* () {
|
||||||
|
return yield* get
|
||||||
|
})
|
||||||
|
|
||||||
|
return TestService.of({ getDir })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(TestService.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultA = await Instance.provide({
|
||||||
|
directory: a.path,
|
||||||
|
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||||
|
})
|
||||||
|
expect(resultA).toBe(a.path)
|
||||||
|
|
||||||
|
// Second call with different directory must NOT return A's directory
|
||||||
|
const resultB = await Instance.provide({
|
||||||
|
directory: b.path,
|
||||||
|
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||||
|
})
|
||||||
|
expect(resultB).toBe(b.path)
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState.get isolates concurrent fibers across real delays, yields, and timer callbacks", async () => {
|
||||||
|
await using a = await tmpdir()
|
||||||
|
await using b = await tmpdir()
|
||||||
|
await using c = await tmpdir()
|
||||||
|
|
||||||
|
// Adversarial: concurrent fibers with real timer delays (macrotask
|
||||||
|
// boundaries via setTimeout/Bun.sleep), explicit scheduler yields,
|
||||||
|
// and many async steps. If ALS context leaks or gets lost at any
|
||||||
|
// point, a fiber will see the wrong directory.
|
||||||
|
|
||||||
|
interface TestApi {
|
||||||
|
readonly getDir: () => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestService extends ServiceMap.Service<TestService, TestApi>()("@test/ALS-adversarial") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
TestService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((dir) => Effect.sync(() => dir))
|
||||||
|
|
||||||
|
const getDir = Effect.fn("TestService.getDir")(function* () {
|
||||||
|
// Mix of async boundary types to maximise interleaving:
|
||||||
|
// 1. Real timer delay (macrotask — setTimeout under the hood)
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
// 2. Effect.sleep (Effect's own timer, uses its internal scheduler)
|
||||||
|
yield* Effect.sleep(Duration.millis(1))
|
||||||
|
// 3. Explicit scheduler yields
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
}
|
||||||
|
// 4. Microtask boundaries
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
yield* Effect.promise(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
// 5. Another Effect.sleep
|
||||||
|
yield* Effect.sleep(Duration.millis(2))
|
||||||
|
// 6. Another real timer to force a second macrotask hop
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
// NOW read the directory — ALS must still be correct
|
||||||
|
return yield* InstanceState.get(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
return TestService.of({ getDir })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(TestService.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [resultA, resultB, resultC] = await Promise.all([
|
||||||
|
Instance.provide({
|
||||||
|
directory: a.path,
|
||||||
|
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||||
|
}),
|
||||||
|
Instance.provide({
|
||||||
|
directory: b.path,
|
||||||
|
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||||
|
}),
|
||||||
|
Instance.provide({
|
||||||
|
directory: c.path,
|
||||||
|
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(resultA).toBe(a.path)
|
||||||
|
expect(resultB).toBe(b.path)
|
||||||
|
expect(resultC).toBe(c.path)
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
|
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
let n = 0
|
let n = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user