Compare commits
2 Commits
dev
...
fix-plugin
| Author | SHA1 | Date |
|---|---|---|
|
|
1e2ac94a91 | |
|
|
52d7475dbf |
|
|
@ -1,210 +0,0 @@
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import z from "zod"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Identifier } from "../id/id"
|
|
||||||
import { Plugin } from "../plugin"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Wildcard } from "../util/wildcard"
|
|
||||||
|
|
||||||
export namespace Permission {
|
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
function toKeys(pattern: Info["pattern"], type: string): string[] {
|
|
||||||
return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern]
|
|
||||||
}
|
|
||||||
|
|
||||||
function covered(keys: string[], approved: Record<string, boolean>): boolean {
|
|
||||||
const pats = Object.keys(approved)
|
|
||||||
return keys.every((k) => pats.some((p) => Wildcard.match(k, p)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
type: z.string(),
|
|
||||||
pattern: z.union([z.string(), z.array(z.string())]).optional(),
|
|
||||||
sessionID: z.string(),
|
|
||||||
messageID: z.string(),
|
|
||||||
callID: z.string().optional(),
|
|
||||||
message: z.string(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
time: z.object({
|
|
||||||
created: z.number(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Permission",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Updated: BusEvent.define("permission.updated", Info),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
permissionID: z.string(),
|
|
||||||
response: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = Instance.state(
|
|
||||||
() => {
|
|
||||||
const pending: {
|
|
||||||
[sessionID: string]: {
|
|
||||||
[permissionID: string]: {
|
|
||||||
info: Info
|
|
||||||
resolve: () => void
|
|
||||||
reject: (e: any) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} = {}
|
|
||||||
|
|
||||||
const approved: {
|
|
||||||
[sessionID: string]: {
|
|
||||||
[permissionID: string]: boolean
|
|
||||||
}
|
|
||||||
} = {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pending,
|
|
||||||
approved,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (state) => {
|
|
||||||
for (const pending of Object.values(state.pending)) {
|
|
||||||
for (const item of Object.values(pending)) {
|
|
||||||
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export function pending() {
|
|
||||||
return state().pending
|
|
||||||
}
|
|
||||||
|
|
||||||
export function list() {
|
|
||||||
const { pending } = state()
|
|
||||||
const result: Info[] = []
|
|
||||||
for (const items of Object.values(pending)) {
|
|
||||||
for (const item of Object.values(items)) {
|
|
||||||
result.push(item.info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.sort((a, b) => a.id.localeCompare(b.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ask(input: {
|
|
||||||
type: Info["type"]
|
|
||||||
message: Info["message"]
|
|
||||||
pattern?: Info["pattern"]
|
|
||||||
callID?: Info["callID"]
|
|
||||||
sessionID: Info["sessionID"]
|
|
||||||
messageID: Info["messageID"]
|
|
||||||
metadata: Info["metadata"]
|
|
||||||
}) {
|
|
||||||
const { pending, approved } = state()
|
|
||||||
log.info("asking", {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.messageID,
|
|
||||||
toolCallID: input.callID,
|
|
||||||
pattern: input.pattern,
|
|
||||||
})
|
|
||||||
const approvedForSession = approved[input.sessionID] || {}
|
|
||||||
const keys = toKeys(input.pattern, input.type)
|
|
||||||
if (covered(keys, approvedForSession)) return
|
|
||||||
const info: Info = {
|
|
||||||
id: Identifier.ascending("permission"),
|
|
||||||
type: input.type,
|
|
||||||
pattern: input.pattern,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.messageID,
|
|
||||||
callID: input.callID,
|
|
||||||
message: input.message,
|
|
||||||
metadata: input.metadata,
|
|
||||||
time: {
|
|
||||||
created: Date.now(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (
|
|
||||||
await Plugin.trigger("permission.ask", info, {
|
|
||||||
status: "ask",
|
|
||||||
}).then((x) => x.status)
|
|
||||||
) {
|
|
||||||
case "deny":
|
|
||||||
throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata)
|
|
||||||
case "allow":
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pending[input.sessionID] = pending[input.sessionID] || {}
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
pending[input.sessionID][info.id] = {
|
|
||||||
info,
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
}
|
|
||||||
Bus.publish(Event.Updated, info)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Response = z.enum(["once", "always", "reject"])
|
|
||||||
export type Response = z.infer<typeof Response>
|
|
||||||
|
|
||||||
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
|
|
||||||
log.info("response", input)
|
|
||||||
const { pending, approved } = state()
|
|
||||||
const match = pending[input.sessionID]?.[input.permissionID]
|
|
||||||
if (!match) return
|
|
||||||
delete pending[input.sessionID][input.permissionID]
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
permissionID: input.permissionID,
|
|
||||||
response: input.response,
|
|
||||||
})
|
|
||||||
if (input.response === "reject") {
|
|
||||||
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
match.resolve()
|
|
||||||
if (input.response === "always") {
|
|
||||||
approved[input.sessionID] = approved[input.sessionID] || {}
|
|
||||||
const approveKeys = toKeys(match.info.pattern, match.info.type)
|
|
||||||
for (const k of approveKeys) {
|
|
||||||
approved[input.sessionID][k] = true
|
|
||||||
}
|
|
||||||
const items = pending[input.sessionID]
|
|
||||||
if (!items) return
|
|
||||||
for (const item of Object.values(items)) {
|
|
||||||
const itemKeys = toKeys(item.info.pattern, item.info.type)
|
|
||||||
if (covered(itemKeys, approved[input.sessionID])) {
|
|
||||||
respond({
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
permissionID: item.info.id,
|
|
||||||
response: input.response,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly sessionID: string,
|
|
||||||
public readonly permissionID: string,
|
|
||||||
public readonly toolCallID?: string,
|
|
||||||
public readonly metadata?: Record<string, any>,
|
|
||||||
public readonly reason?: string,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
reason !== undefined
|
|
||||||
? reason
|
|
||||||
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -120,28 +120,57 @@ export namespace PermissionNext {
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const s = await state()
|
const s = await state()
|
||||||
const { ruleset, ...request } = input
|
const { ruleset, ...request } = input
|
||||||
for (const pattern of request.patterns ?? []) {
|
const ask = (request.patterns ?? []).reduce((ask, pattern) => {
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
|
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
if (rule.action === "deny")
|
if (rule.action === "deny") {
|
||||||
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
|
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
|
||||||
if (rule.action === "ask") {
|
}
|
||||||
|
return ask || rule.action === "ask"
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
if (!ask) return
|
||||||
|
|
||||||
const id = input.id ?? Identifier.ascending("permission")
|
const id = input.id ?? Identifier.ascending("permission")
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const info: Request = {
|
const info: Request = {
|
||||||
id,
|
id,
|
||||||
...request,
|
...request,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
s.pending[id] = {
|
s.pending[id] = {
|
||||||
info,
|
info,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void import("@/plugin")
|
||||||
|
.then((plugin) =>
|
||||||
|
plugin.Plugin.trigger("permission.ask", info, {
|
||||||
|
status: "ask" as "ask" | "allow" | "deny",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
|
const existing = s.pending[id]
|
||||||
|
if (!existing) return
|
||||||
|
if (result.status === "deny") {
|
||||||
|
delete s.pending[id]
|
||||||
|
existing.reject(new RejectedError())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status === "allow") {
|
||||||
|
delete s.pending[id]
|
||||||
|
existing.resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
Bus.publish(Event.Asked, info)
|
Bus.publish(Event.Asked, info)
|
||||||
})
|
})
|
||||||
}
|
.catch((error) => {
|
||||||
if (rule.action === "allow") continue
|
log.error("permission.ask plugin failed", { error })
|
||||||
}
|
if (!s.pending[id]) return
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue