From c176b46bf951ca76a67c3685469789f27ca7ec43 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 18 Dec 2025 17:58:18 -0500 Subject: [PATCH] sync --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/permission/next.ts | 117 +++-- packages/opencode/src/session/llm.ts | 10 +- packages/opencode/src/session/prompt.ts | 7 - packages/opencode/src/tool/registry.ts | 17 - packages/opencode/src/tool/write.ts | 3 +- packages/opencode/test/agent/agent.test.ts | 53 ++- packages/opencode/test/config/config.test.ts | 248 +++++++++++ .../opencode/test/permission/next.test.ts | 400 ++++++++++++++---- packages/opencode/test/tool/registry.test.ts | 120 ------ packages/sdk/js/src/v2/gen/types.gen.ts | 110 +++-- 11 files changed, 736 insertions(+), 351 deletions(-) delete mode 100644 packages/opencode/test/tool/registry.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e4ecba155..16f8d14d4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -450,7 +450,7 @@ export namespace Config { permission: Permission.optional(), }) .catchall(z.any()) - .transform((agent) => { + .transform((agent, ctx) => { const knownKeys = new Set([ "model", "prompt", diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 84e349356..78c827d37 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -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 + + export const Rule = z + .object({ + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) export type Rule = z.infer - 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 @@ -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 - export const Response = z.enum(["once", "always", "reject"]) - export type Response = z.infer + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer 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 - 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 { + const disabled = new Set() + 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 { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 64a80655e..2f02516ea 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -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) { - 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 } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ebd54a6c8..a28c83585 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -578,13 +578,7 @@ export namespace SessionPrompt { }) { using _ = log.time("resolveTools") const tools: Record = {} - 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 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 143267d10..4e4c3a480 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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> { - const result: Record = {} - - 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 - } } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 190a5883d..da7775c56 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -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)], diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 4b6149cb3..c11ebfbf0 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -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") }, }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index ea0737042..fa76636c4 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -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", + }) + }, + }) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 9d81d7eb3..e624da08b 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -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) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts deleted file mode 100644 index 262831dbc..000000000 --- a/packages/opencode/test/tool/registry.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5d119b8d0..697a9a181 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -450,15 +450,18 @@ export type EventMessagePartRemoved = { export type PermissionRequest = { id: string sessionID: string - type: string + patterns: Array title: string description: string - keys: Array - patterns?: Array + metadata: { + [key: string]: unknown + } + always: Array + 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 - } -} - 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 + } +} + 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 = {