This commit is contained in:
Dax Raad 2025-12-18 17:58:18 -05:00
parent 1898bc6574
commit c176b46bf9
11 changed files with 736 additions and 351 deletions

View file

@ -450,7 +450,7 @@ export namespace Config {
permission: Permission.optional(),
})
.catchall(z.any())
.transform((agent) => {
.transform((agent, ctx) => {
const knownKeys = new Set([
"model",
"prompt",

View file

@ -12,12 +12,22 @@ import z from "zod"
export namespace PermissionNext {
const log = Log.create({ service: "permission" })
export const Rule = Config.PermissionObject.meta({
ref: "PermissionRule",
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = z.record(z.string(), Rule).meta({
export const Ruleset = z.record(z.string(), Rule.array()).meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
@ -26,12 +36,15 @@ export namespace PermissionNext {
const ruleset: Ruleset = {}
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset[key] = {
"*": value,
}
ruleset[key] = [
{
action: value,
pattern: "*",
},
]
continue
}
ruleset[key] = value
ruleset[key] = Object.entries(value).map(([pattern, action]) => ({ pattern, action }))
}
return ruleset
}
@ -39,37 +52,27 @@ export namespace PermissionNext {
export function merge(...rulesets: Ruleset[]): Ruleset {
const result: Ruleset = {}
for (const ruleset of rulesets) {
for (const [permission, rule] of Object.entries(ruleset)) {
for (const existingPerm of Object.keys(result)) {
if (Wildcard.match(existingPerm, permission)) {
for (const [pattern, action] of Object.entries(rule)) {
for (const existingPattern of Object.keys(result[existingPerm])) {
if (Wildcard.match(existingPattern, pattern)) {
result[existingPerm][existingPattern] = action
}
}
}
}
}
result[permission] ??= {}
for (const [pattern, action] of Object.entries(rule)) {
result[permission][pattern] = action
for (const [permission, rules] of Object.entries(ruleset)) {
if (!result[permission]) {
result[permission] = rules
continue
}
result[permission] = result[permission].concat(rules)
}
}
return result
return result as Ruleset
}
export const Request = z
.object({
id: Identifier.schema("permission"),
sessionID: Identifier.schema("session"),
permission: z.string(),
patterns: z.string().array(),
title: z.string(),
description: z.string(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
permission: z.string(),
})
.meta({
ref: "PermissionRequest",
@ -77,8 +80,8 @@ export namespace PermissionNext {
export type Request = z.infer<typeof Request>
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: z.string(),
@ -86,7 +89,15 @@ export namespace PermissionNext {
})
export const Event = {
Requested: BusEvent.define("permission.requested", Request),
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
reply: Reply,
}),
),
}
const state = Instance.state(() => {
@ -109,7 +120,7 @@ export namespace PermissionNext {
}
})
export const request = fn(
export const ask = fn(
Request.partial({ id: true }).extend({
ruleset: Ruleset,
}),
@ -132,7 +143,7 @@ export namespace PermissionNext {
resolve,
reject,
}
Bus.publish(Event.Requested, info)
Bus.publish(Event.Asked, info)
})
}
if (action === "allow") continue
@ -142,38 +153,62 @@ export namespace PermissionNext {
export const respond = fn(
z.object({
permissionID: Identifier.schema("permission"),
response: Response,
requestID: Identifier.schema("permission"),
response: Reply,
}),
async (input) => {
const existing = state().pending[input.permissionID]
const existing = state().pending[input.requestID]
if (!existing) return
delete state().pending[input.requestID]
if (input.response === "reject") {
existing.reject(new RejectedError())
return
}
if (input.response === "once") {
existing.resolve()
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.response,
})
return
}
if (input.response === "always") {
existing.resolve()
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.response,
})
return
}
},
)
export const Action = z.enum(["allow", "deny", "ask"])
export type Action = z.infer<typeof Action>
export function evaluate(permission: string, pattern: string, ruleset: Ruleset): Action {
log.info("evaluate", { permission, pattern, ruleset })
for (const [permissionPattern, rule] of sortBy(Object.entries(ruleset), [([k]) => k.length, "desc"])) {
if (!Wildcard.match(permission, permissionPattern)) continue
for (const [p, action] of sortBy(Object.entries(rule), [([k]) => k.length, "desc"])) {
if (!Wildcard.match(pattern, p)) continue
return action
const rules: Rule[] = []
const entries = sortBy(Object.entries(ruleset), ([k]) => k.length)
for (const [permPattern, permRules] of entries) {
if (Wildcard.match(permission, permPattern)) {
rules.push(...permRules)
}
}
return "ask"
const match = rules.findLast((rule) => Wildcard.match(pattern, rule.pattern))
return match?.action ?? "ask"
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
export function disabledTools(tools: string[], ruleset: Ruleset): Set<string> {
const disabled = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
if (evaluate(permission, "*", ruleset) === "deny") {
disabled.add(tool)
}
}
return disabled
}
export class RejectedError extends Error {

View file

@ -9,8 +9,8 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
export namespace LLM {
const log = Log.create({ service: "llm" })
@ -188,9 +188,11 @@ export namespace LLM {
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const enabled = pipe({}, mergeDeep(await ToolRegistry.enabled(input.agent)), mergeDeep(input.user.tools ?? {}))
for (const [key, value] of Object.entries(enabled)) {
if (value === false) delete input.tools[key]
const disabled = PermissionNext.disabledTools(Object.keys(input.tools), input.agent.permission)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
delete input.tools[tool]
}
}
return input.tools
}

View file

@ -578,13 +578,7 @@ export namespace SessionPrompt {
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const enabledTools = pipe(
input.agent.tools,
mergeDeep(await ToolRegistry.enabled(input.agent)),
mergeDeep(input.tools ?? {}),
)
for (const item of await ToolRegistry.tools(input.model.providerID)) {
if (Wildcard.all(item.id, enabledTools) === false) continue
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@ -647,7 +641,6 @@ export namespace SessionPrompt {
})
}
for (const [key, item] of Object.entries(await MCP.tools())) {
if (Wildcard.all(key, enabledTools) === false) continue
const execute = item.execute
if (!execute) continue

View file

@ -10,7 +10,6 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Config } from "../config/config"
@ -132,20 +131,4 @@ export namespace ToolRegistry {
)
return result
}
export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {}
for (const [tool, action] of Object.entries(agent.permission)) {
if (!Bun.deepEquals(action, { "*": "deny" })) continue
result[tool] = false
if (tool === "edit") {
result["write"] = false
result["patch"] = false
result["multiedit"] = false
}
}
return result
}
}

View file

@ -2,7 +2,6 @@ import z from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { Bus } from "../bus"
import { File } from "../file"
@ -60,7 +59,7 @@ export const WriteTool = Tool.define("write", {
const exists = await file.exists()
if (exists) await FileTime.assert(ctx.sessionID, filepath)
await PermissionNext.request({
await PermissionNext.ask({
permission: "edit",
title: "tbd",
patterns: [path.relative(Instance.worktree, filepath)],

View file

@ -2,6 +2,13 @@ import { test, expect } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission/next"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
if (!agent) return undefined
return PermissionNext.evaluate(permission, "*", agent.permission)
}
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
@ -30,8 +37,8 @@ test("build agent has correct default properties", async () => {
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(build?.permission.edit?.["*"]).toBe("allow")
expect(build?.permission.bash?.["*"]).toBe("allow")
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "bash")).toBe("allow")
},
})
})
@ -43,8 +50,10 @@ test("plan agent denies edits except .opencode/plan/*", async () => {
fn: async () => {
const plan = await Agent.get("plan")
expect(plan).toBeDefined()
expect(plan?.permission.edit?.["*"]).toBe("deny")
expect(plan?.permission.edit?.[".opencode/plan/*"]).toBe("allow")
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow")
},
})
})
@ -57,10 +66,10 @@ test("explore agent denies edit and write", async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
expect(explore?.mode).toBe("subagent")
expect(explore?.permission.edit?.["*"]).toBe("deny")
expect(explore?.permission.write?.["*"]).toBe("deny")
expect(explore?.permission.todoread?.["*"]).toBe("deny")
expect(explore?.permission.todowrite?.["*"]).toBe("deny")
expect(evalPerm(explore, "edit")).toBe("deny")
expect(evalPerm(explore, "write")).toBe("deny")
expect(evalPerm(explore, "todoread")).toBe("deny")
expect(evalPerm(explore, "todowrite")).toBe("deny")
},
})
})
@ -74,8 +83,8 @@ test("general agent denies todo tools", async () => {
expect(general).toBeDefined()
expect(general?.mode).toBe("subagent")
expect(general?.hidden).toBe(true)
expect(general?.permission.todoread?.["*"]).toBe("deny")
expect(general?.permission.todowrite?.["*"]).toBe("deny")
expect(evalPerm(general, "todoread")).toBe("deny")
expect(evalPerm(general, "todowrite")).toBe("deny")
},
})
})
@ -88,7 +97,9 @@ test("compaction agent denies all permissions", async () => {
const compaction = await Agent.get("compaction")
expect(compaction).toBeDefined()
expect(compaction?.hidden).toBe(true)
expect(compaction?.permission["*"]?.["*"]).toBe("deny")
expect(evalPerm(compaction, "bash")).toBe("deny")
expect(evalPerm(compaction, "edit")).toBe("deny")
expect(evalPerm(compaction, "read")).toBe("deny")
},
})
})
@ -189,8 +200,10 @@ test("agent permission config merges with defaults", async () => {
fn: async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
expect(build?.permission.bash?.["rm -rf *"]).toBe("deny")
expect(build?.permission.edit?.["*"]).toBe("allow")
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
},
})
})
@ -208,7 +221,7 @@ test("global permission config applies to all agents", async () => {
fn: async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
expect(build?.permission.bash?.["*"]).toBe("deny")
expect(evalPerm(build, "bash")).toBe("deny")
},
})
})
@ -373,8 +386,8 @@ test("default permission includes doom_loop and external_directory as ask", asyn
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.permission.doom_loop?.["*"]).toBe("ask")
expect(build?.permission.external_directory?.["*"]).toBe("ask")
expect(evalPerm(build, "doom_loop")).toBe("ask")
expect(evalPerm(build, "external_directory")).toBe("ask")
},
})
})
@ -385,7 +398,7 @@ test("webfetch is allowed by default", async () => {
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.permission.webfetch?.["*"]).toBe("allow")
expect(evalPerm(build, "webfetch")).toBe("allow")
},
})
})
@ -407,8 +420,8 @@ test("legacy tools config converts to permissions", async () => {
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.permission.bash?.["*"]).toBe("deny")
expect(build?.permission.read?.["*"]).toBe("deny")
expect(evalPerm(build, "bash")).toBe("deny")
expect(evalPerm(build, "read")).toBe("deny")
},
})
})
@ -429,7 +442,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.permission.edit?.["*"]).toBe("deny")
expect(evalPerm(build, "edit")).toBe("deny")
},
})
})

View file

@ -507,3 +507,251 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
},
})
})
// Legacy tools migration tests
test("migrates legacy tools config to permissions - allow", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
bash: true,
read: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
read: "allow",
})
},
})
})
test("migrates legacy tools config to permissions - deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
bash: false,
webfetch: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
webfetch: "deny",
})
},
})
})
test("migrates legacy write tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
write: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
},
})
})
test("migrates legacy edit tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
edit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates legacy patch tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
patch: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
},
})
})
test("migrates legacy multiedit tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
multiedit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates mixed legacy tools config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
bash: true,
write: true,
read: false,
webfetch: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
edit: "allow",
read: "deny",
webfetch: "allow",
})
},
})
})
test("merges legacy tools with existing permission config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
permission: {
glob: "allow",
},
tools: {
bash: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
})
},
})
})

View file

@ -3,14 +3,19 @@ import { PermissionNext } from "../../src/permission/next"
// fromConfig tests
test("fromConfig - string value becomes wildcard", () => {
test("fromConfig - string value becomes wildcard rule", () => {
const result = PermissionNext.fromConfig({ bash: "allow" })
expect(result).toEqual({ bash: { "*": "allow" } })
expect(result).toEqual({ bash: [{ pattern: "*", action: "allow" }] })
})
test("fromConfig - object value stays as-is", () => {
test("fromConfig - object value converts to rules array", () => {
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual({
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "rm", action: "deny" },
],
})
})
test("fromConfig - mixed string and object values", () => {
@ -20,9 +25,12 @@ test("fromConfig - mixed string and object values", () => {
webfetch: "ask",
})
expect(result).toEqual({
bash: { "*": "allow", rm: "deny" },
edit: { "*": "allow" },
webfetch: { "*": "ask" },
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "rm", action: "deny" },
],
edit: [{ pattern: "*", action: "allow" }],
webfetch: [{ pattern: "*", action: "ask" }],
})
})
@ -33,127 +41,144 @@ test("fromConfig - empty object", () => {
// merge tests
test("merge - simple override", () => {
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { "*": "deny" } })
expect(result).toEqual({ bash: { "*": "deny" } })
})
test("merge - adds new permission", () => {
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { edit: { "*": "deny" } })
test("merge - simple concatenation", () => {
const result = PermissionNext.merge(
{ bash: [{ pattern: "*", action: "allow" }] },
{ bash: [{ pattern: "*", action: "deny" }] },
)
expect(result).toEqual({
bash: { "*": "allow" },
edit: { "*": "deny" },
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "*", action: "deny" },
],
})
})
test("merge - wildcard overwrites specific patterns", () => {
const result = PermissionNext.merge({ bash: { foo: "ask", bar: "allow" } }, { bash: { "*": "deny" } })
expect(result).toEqual({ bash: { foo: "deny", bar: "deny", "*": "deny" } })
})
test("merge - specific pattern after wildcard", () => {
const result = PermissionNext.merge({ bash: { "*": "deny" } }, { bash: { foo: "allow" } })
expect(result).toEqual({ bash: { "*": "deny", foo: "allow" } })
})
test("merge - glob pattern overwrites matching patterns", () => {
test("merge - adds new permission", () => {
const result = PermissionNext.merge(
{ bash: { "foo/bar": "ask", "foo/baz": "allow", other: "deny" } },
{ bash: { "foo/*": "deny" } },
{ bash: [{ pattern: "*", action: "allow" }] },
{ edit: [{ pattern: "*", action: "deny" }] },
)
expect(result).toEqual({ bash: { "foo/bar": "deny", "foo/baz": "deny", other: "deny", "foo/*": "deny" } })
expect(result).toEqual({
bash: [{ pattern: "*", action: "allow" }],
edit: [{ pattern: "*", action: "deny" }],
})
})
test("merge - concatenates rules for same permission", () => {
const result = PermissionNext.merge(
{ bash: [{ pattern: "foo", action: "ask" }] },
{ bash: [{ pattern: "*", action: "deny" }] },
)
expect(result).toEqual({
bash: [
{ pattern: "foo", action: "ask" },
{ pattern: "*", action: "deny" },
],
})
})
test("merge - multiple rulesets", () => {
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { rm: "ask" } }, { edit: { "*": "allow" } })
const result = PermissionNext.merge(
{ bash: [{ pattern: "*", action: "allow" }] },
{ bash: [{ pattern: "rm", action: "ask" }] },
{ edit: [{ pattern: "*", action: "allow" }] },
)
expect(result).toEqual({
bash: { "*": "allow", rm: "ask" },
edit: { "*": "allow" },
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "rm", action: "ask" },
],
edit: [{ pattern: "*", action: "allow" }],
})
})
test("merge - empty ruleset does nothing", () => {
const result = PermissionNext.merge({ bash: { "*": "allow" } }, {})
expect(result).toEqual({ bash: { "*": "allow" } })
const result = PermissionNext.merge({ bash: [{ pattern: "*", action: "allow" }] }, {})
expect(result).toEqual({ bash: [{ pattern: "*", action: "allow" }] })
})
test("merge - nested glob patterns overwrites matching", () => {
test("merge - preserves rule order", () => {
const result = PermissionNext.merge(
{ edit: { "src/components/Button.tsx": "allow", "src/components/Input.tsx": "allow" } },
{ edit: { "src/components/*": "deny" } },
{
edit: [
{ pattern: "src/*", action: "allow" },
{ pattern: "src/secret/*", action: "deny" },
],
},
{ edit: [{ pattern: "src/secret/ok.ts", action: "allow" }] },
)
expect(result).toEqual({
edit: { "src/components/Button.tsx": "deny", "src/components/Input.tsx": "deny", "src/components/*": "deny" },
})
})
test("merge - non-matching glob preserves existing", () => {
const result = PermissionNext.merge({ edit: { "src/foo.ts": "allow" } }, { edit: { "test/*": "deny" } })
expect(result).toEqual({ edit: { "src/foo.ts": "allow", "test/*": "deny" } })
})
test("merge - wildcard permission overwrites all other permissions", () => {
const result = PermissionNext.merge(
{ bash: { "/bin/ls": "allow" }, edit: { "src/*": "allow" } },
{ "*": { "*": "ask" } },
)
// The wildcard permission should overwrite existing permissions' values
expect(result).toEqual({
bash: { "/bin/ls": "ask" },
edit: { "src/*": "ask" },
"*": { "*": "ask" },
edit: [
{ pattern: "src/*", action: "allow" },
{ pattern: "src/secret/*", action: "deny" },
{ pattern: "src/secret/ok.ts", action: "allow" },
],
})
})
// evaluate tests
test("evaluate - exact permission and pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", { bash: { rm: "deny" } })
test("evaluate - exact pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", { bash: [{ pattern: "rm", action: "deny" }] })
expect(result).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", { bash: { "*": "allow" } })
const result = PermissionNext.evaluate("bash", "rm", { bash: [{ pattern: "*", action: "allow" }] })
expect(result).toBe("allow")
})
test("evaluate - specific pattern takes precedence over wildcard", () => {
const result = PermissionNext.evaluate("bash", "rm", { bash: { "*": "allow", rm: "deny" } })
test("evaluate - last matching rule wins", () => {
const result = PermissionNext.evaluate("bash", "rm", {
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "rm", action: "deny" },
],
})
expect(result).toBe("deny")
})
test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = PermissionNext.evaluate("bash", "rm", {
bash: [
{ pattern: "rm", action: "deny" },
{ pattern: "*", action: "allow" },
],
})
expect(result).toBe("allow")
})
test("evaluate - glob pattern match", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", { edit: { "src/*": "allow" } })
const result = PermissionNext.evaluate("edit", "src/foo.ts", { edit: [{ pattern: "src/*", action: "allow" }] })
expect(result).toBe("allow")
})
test("evaluate - more specific glob takes precedence", () => {
test("evaluate - last matching glob wins", () => {
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", {
edit: { "src/*": "deny", "src/components/*": "allow" },
edit: [
{ pattern: "src/*", action: "deny" },
{ pattern: "src/components/*", action: "allow" },
],
})
expect(result).toBe("allow")
})
test("evaluate - wildcard permission match", () => {
const result = PermissionNext.evaluate("bash", "rm", { "*": { "*": "deny" } })
test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", {
edit: [
{ pattern: "src/components/*", action: "allow" },
{ pattern: "src/*", action: "deny" },
],
})
expect(result).toBe("deny")
})
test("evaluate - specific permission takes precedence over wildcard permission", () => {
const result = PermissionNext.evaluate("bash", "rm", {
"*": { "*": "deny" },
bash: { "*": "allow" },
test("evaluate - unknown permission returns ask", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", {
bash: [{ pattern: "*", action: "allow" }],
})
expect(result).toBe("allow")
})
test("evaluate - unknown permission with wildcard fallback", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", { "*": { "*": "ask" } })
expect(result).toBe("ask")
})
test("evaluate - unknown permission without wildcard returns ask", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", { bash: { "*": "allow" } })
expect(result).toBe("ask")
})
@ -163,29 +188,218 @@ test("evaluate - empty ruleset returns ask", () => {
})
test("evaluate - no matching pattern returns ask", () => {
const result = PermissionNext.evaluate("edit", "etc/passwd", { edit: { "src/*": "allow" } })
const result = PermissionNext.evaluate("edit", "etc/passwd", { edit: [{ pattern: "src/*", action: "allow" }] })
expect(result).toBe("ask")
})
test("evaluate - glob permission pattern", () => {
const result = PermissionNext.evaluate("mcp_server_tool", "anything", {
"mcp_*": { "*": "allow" },
test("evaluate - empty rules array returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", { bash: [] })
expect(result).toBe("ask")
})
test("evaluate - multiple matching patterns, last wins", () => {
const result = PermissionNext.evaluate("edit", "src/secret.ts", {
edit: [
{ pattern: "*", action: "ask" },
{ pattern: "src/*", action: "allow" },
{ pattern: "src/secret.ts", action: "deny" },
],
})
expect(result).toBe("deny")
})
test("evaluate - non-matching patterns are skipped", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", {
edit: [
{ pattern: "*", action: "ask" },
{ pattern: "test/*", action: "deny" },
{ pattern: "src/*", action: "allow" },
],
})
expect(result).toBe("allow")
})
test("evaluate - specific permission over glob permission", () => {
const result = PermissionNext.evaluate("mcp_dangerous", "anything", {
"mcp_*": { "*": "allow" },
mcp_dangerous: { "*": "deny" },
test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", {
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "/bin/rm", action: "deny" },
],
})
expect(result).toBe("deny")
})
test("evaluate - combined permission and pattern specificity", () => {
const result = PermissionNext.evaluate("edit", "src/secret.ts", {
"*": { "*": "ask" },
edit: { "*": "allow", "src/secret.ts": "deny" },
test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", {
bash: [
{ pattern: "/bin/rm", action: "deny" },
{ pattern: "*", action: "allow" },
],
})
expect(result).toBe("allow")
})
// wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => {
const result = PermissionNext.evaluate("bash", "rm", {
"*": [{ pattern: "*", action: "deny" }],
})
expect(result).toBe("deny")
})
test("evaluate - wildcard permission with specific pattern", () => {
const result = PermissionNext.evaluate("bash", "rm", {
"*": [{ pattern: "rm", action: "deny" }],
})
expect(result).toBe("deny")
})
test("evaluate - glob permission pattern", () => {
const result = PermissionNext.evaluate("mcp_server_tool", "anything", {
"mcp_*": [{ pattern: "*", action: "allow" }],
})
expect(result).toBe("allow")
})
test("evaluate - specific permission and wildcard permission combined", () => {
const result = PermissionNext.evaluate("bash", "rm", {
"*": [{ pattern: "*", action: "deny" }],
bash: [{ pattern: "*", action: "allow" }],
})
expect(result).toBe("allow")
})
test("evaluate - wildcard permission does not match when specific exists", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", {
"*": [{ pattern: "*", action: "deny" }],
edit: [{ pattern: "src/*", action: "allow" }],
})
expect(result).toBe("allow")
})
test("evaluate - multiple matching permission patterns combine rules", () => {
const result = PermissionNext.evaluate("mcp_dangerous", "anything", {
"*": [{ pattern: "*", action: "ask" }],
"mcp_*": [{ pattern: "*", action: "allow" }],
mcp_dangerous: [{ pattern: "*", action: "deny" }],
})
expect(result).toBe("deny")
})
test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", {
"*": [{ pattern: "*", action: "ask" }],
bash: [{ pattern: "*", action: "allow" }],
})
expect(result).toBe("ask")
})
test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win
const result = PermissionNext.evaluate("bash", "rm", {
bash: [{ pattern: "*", action: "allow" }],
"*": [{ pattern: "*", action: "deny" }],
})
expect(result).toBe("allow")
})
// disabledTools tests
test("disabledTools - returns empty set when all tools allowed", () => {
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
"*": [{ pattern: "*", action: "allow" }],
})
expect(result.size).toBe(0)
})
test("disabledTools - disables tool when denied", () => {
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
bash: [{ pattern: "*", action: "deny" }],
"*": [{ pattern: "*", action: "allow" }],
})
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(false)
expect(result.has("read")).toBe(false)
})
test("disabledTools - disables edit/write/patch/multiedit when edit denied", () => {
const result = PermissionNext.disabledTools(["edit", "write", "patch", "multiedit", "bash"], {
edit: [{ pattern: "*", action: "deny" }],
"*": [{ pattern: "*", action: "allow" }],
})
expect(result.has("edit")).toBe(true)
expect(result.has("write")).toBe(true)
expect(result.has("patch")).toBe(true)
expect(result.has("multiedit")).toBe(true)
expect(result.has("bash")).toBe(false)
})
test("disabledTools - does not disable when partially denied", () => {
const result = PermissionNext.disabledTools(["bash"], {
bash: [
{ pattern: "*", action: "allow" },
{ pattern: "rm *", action: "deny" },
],
})
expect(result.has("bash")).toBe(false)
})
test("disabledTools - does not disable when action is ask", () => {
const result = PermissionNext.disabledTools(["bash", "edit"], {
"*": [{ pattern: "*", action: "ask" }],
})
expect(result.size).toBe(0)
})
test("disabledTools - disables when wildcard deny even with specific allow", () => {
// Tool is disabled because evaluate("bash", "*", ...) returns "deny"
// The "echo *" allow rule doesn't match the "*" pattern we're checking
const result = PermissionNext.disabledTools(["bash"], {
bash: [
{ pattern: "*", action: "deny" },
{ pattern: "echo *", action: "allow" },
],
})
expect(result.has("bash")).toBe(true)
})
test("disabledTools - does not disable when wildcard allow after deny", () => {
const result = PermissionNext.disabledTools(["bash"], {
bash: [
{ pattern: "rm *", action: "deny" },
{ pattern: "*", action: "allow" },
],
})
expect(result.has("bash")).toBe(false)
})
test("disabledTools - disables multiple tools", () => {
const result = PermissionNext.disabledTools(["bash", "edit", "webfetch"], {
bash: [{ pattern: "*", action: "deny" }],
edit: [{ pattern: "*", action: "deny" }],
webfetch: [{ pattern: "*", action: "deny" }],
})
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("webfetch")).toBe(true)
})
test("disabledTools - wildcard permission denies all tools", () => {
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
"*": [{ pattern: "*", action: "deny" }],
})
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})
test("disabledTools - specific allow overrides wildcard deny", () => {
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
"*": [{ pattern: "*", action: "deny" }],
bash: [{ pattern: "*", action: "allow" }],
})
expect(result.has("bash")).toBe(false)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})

View file

@ -1,120 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ToolRegistry } from "../../src/tool/registry"
import type { Agent } from "../../src/agent/agent"
describe("ToolRegistry.enabled", () => {
test("returns empty object when all tools allowed", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
edit: { "*": "allow" },
bash: { "*": "allow" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
expect(result).toEqual({})
})
test("disables edit tools when edit is denied", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
edit: { "*": "deny" },
bash: { "*": "allow" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
expect(result.edit).toBe(false)
expect(result.write).toBe(false)
expect(result.patch).toBe(false)
expect(result.multiedit).toBe(false)
})
test("disables specific tool when denied with wildcard", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
bash: { "*": "deny" },
edit: { "*": "allow" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
expect(result.bash).toBe(false)
})
test("does not disable tool when partially denied", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
bash: {
"rm *": "deny",
"*": "allow",
},
edit: { "*": "allow" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
expect(result.bash).toBeUndefined()
})
test("disables multiple tools when multiple denied", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
edit: { "*": "deny" },
bash: { "*": "deny" },
webfetch: { "*": "deny" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
expect(result.edit).toBe(false)
expect(result.write).toBe(false)
expect(result.patch).toBe(false)
expect(result.multiedit).toBe(false)
expect(result.bash).toBe(false)
expect(result.webfetch).toBe(false)
})
test("does not disable tool when action is ask", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
edit: { "*": "ask" },
bash: { "*": "ask" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
expect(result.edit).toBeUndefined()
expect(result.bash).toBeUndefined()
})
test("does not disable tool when wildcard deny has additional allow rules", async () => {
const agent: Agent.Info = {
name: "test",
mode: "primary",
permission: {
bash: {
"*": "deny",
"echo *": "allow",
},
edit: { "*": "allow" },
},
options: {},
}
const result = await ToolRegistry.enabled(agent)
// bash should NOT be disabled because there's an allow rule for "echo *"
expect(result.bash).toBeUndefined()
})
})

View file

@ -450,15 +450,18 @@ export type EventMessagePartRemoved = {
export type PermissionRequest = {
id: string
sessionID: string
type: string
patterns: Array<string>
title: string
description: string
keys: Array<string>
patterns?: Array<string>
metadata: {
[key: string]: unknown
}
always: Array<string>
permission: string
}
export type EventPermissionRequest = {
type: "permission.request"
export type EventPermissionRequested = {
type: "permission.requested"
properties: PermissionRequest
}
@ -492,40 +495,6 @@ export type EventPermissionReplied = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type SessionStatus =
| {
type: "idle"
@ -562,6 +531,40 @@ export type EventSessionCompacted = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
@ -760,14 +763,14 @@ export type Event =
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventPermissionRequest
| EventPermissionRequested
| EventPermissionUpdated
| EventPermissionReplied
| EventFileEdited
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventFileEdited
| EventTodoUpdated
| EventCommandExecuted
| EventSessionCreated
| EventSessionUpdated
@ -1159,6 +1162,7 @@ export type PermissionConfig = {
external_directory?: PermissionRuleConfig
todowrite?: PermissionActionConfig
todoread?: PermissionActionConfig
webfetch?: PermissionActionConfig
websearch?: PermissionActionConfig
codesearch?: PermissionActionConfig
doom_loop?: PermissionActionConfig
@ -1170,6 +1174,9 @@ export type AgentConfig = {
temperature?: number
top_p?: number
prompt?: string
/**
* @deprecated Use 'permission' field instead
*/
tools?: {
[key: string]: boolean
}
@ -1179,6 +1186,9 @@ export type AgentConfig = {
*/
description?: string
mode?: "subagent" | "primary" | "all"
options?: {
[key: string]: unknown
}
/**
* Hex color code for the agent (e.g., #FF5733)
*/
@ -1186,6 +1196,10 @@ export type AgentConfig = {
/**
* Maximum number of agentic iterations before forcing text-only response
*/
steps?: number
/**
* @deprecated Use 'steps' field instead.
*/
maxSteps?: number
permission?: PermissionConfig
[key: string]:
@ -1199,6 +1213,9 @@ export type AgentConfig = {
| "subagent"
| "primary"
| "all"
| {
[key: string]: unknown
}
| string
| number
| PermissionConfig
@ -1770,12 +1787,13 @@ export type File = {
status: "added" | "deleted" | "modified"
}
export type PermissionRule = {
[key: string]: PermissionActionConfig
}
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRuleset = {
[key: string]: PermissionRule
[key: string]: Array<{
pattern: string
action: PermissionAction
}>
}
export type Agent = {