From 0dad18f1e5628c2cfd6af393b3133be6daf7ff56 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 17 Dec 2025 19:11:11 -0500 Subject: [PATCH] sync --- packages/opencode/src/agent/agent.ts | 180 ++++-------------- packages/opencode/src/permission/next.ts | 44 ++++- .../opencode/test/permission/next.test.ts | 91 +++++++++ 3 files changed, 167 insertions(+), 148 deletions(-) create mode 100644 packages/opencode/test/permission/next.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8eab56ba3..d0cd1e087 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,10 +4,6 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" -import { mergeDeep } from "remeda" -import { Log } from "../util/log" - -const log = Log.create({ service: "agent" }) import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -15,6 +11,7 @@ import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission/next" +import { mergeDeep } from "remeda" export namespace Agent { export const Info = z @@ -24,7 +21,6 @@ export namespace Agent { mode: z.enum(["subagent", "primary", "all"]), native: z.boolean().optional(), hidden: z.boolean().optional(), - default: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), @@ -37,7 +33,7 @@ export namespace Agent { .optional(), prompt: z.string().optional(), options: z.record(z.string(), z.any()), - maxSteps: z.number().int().positive().optional(), + steps: z.number().int().positive().optional(), }) .meta({ ref: "Agent", @@ -46,29 +42,13 @@ export namespace Agent { const state = Instance.state(async () => { const cfg = await Config.get() - const defaultTools = cfg.tools ?? {} - const permission: PermissionNext.Ruleset = mergeDeep( - { - "*": { - "*": "allow", - }, - edit: { - "*": "allow", - }, - bash: { - "*": "allow", - }, - webfetch: { - "*": "allow", - }, - doom_loop: { - "*": "ask", - }, - external_directory: { - "*": "ask", - }, - }, + const permission: PermissionNext.Ruleset = PermissionNext.merge( + PermissionNext.fromConfig({ + "*": "allow", + doom_loop: "ask", + external_directory: "ask", + }), PermissionNext.fromConfig(cfg.permission ?? {}), ) @@ -83,19 +63,22 @@ export namespace Agent { plan: { name: "plan", options: {}, - permission: mergeDeep(permission, { - edit: { - "*": "deny", - ".opencode/plan/*": "allow", - }, - }), + permission: PermissionNext.merge( + permission, + PermissionNext.fromConfig({ + edit: { + "*": "deny", + ".opencode/plan/*.md": "allow", + }, + }), + ), mode: "primary", native: true, }, general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: mergeDeep( + permission: PermissionNext.merge( permission, PermissionNext.fromConfig({ todoread: "deny", @@ -109,13 +92,18 @@ export namespace Agent { }, explore: { name: "explore", - permission: mergeDeep( + permission: PermissionNext.merge( permission, PermissionNext.fromConfig({ - todoread: "deny", - todowrite: "deny", - edit: "deny", - write: "deny", + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", }), ), description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, @@ -169,65 +157,22 @@ export namespace Agent { item = result[key] = { name: key, mode: "all", - permission: permission, + permission, options: {}, native: false, } - const { - name, - model, - prompt, - tools, - description, - temperature, - top_p, - mode, - permission, - color, - maxSteps, - ...extra - } = value - item.options = { - ...item.options, - ...extra, - } - if (model) item.model = Provider.parseModel(model) - if (prompt) item.prompt = prompt - if (tools) - item.tools = { - ...item.tools, - ...tools, - } - item.tools = { - ...defaultTools, - ...item.tools, - } - if (description) item.description = description - if (temperature != undefined) item.temperature = temperature - if (top_p != undefined) item.topP = top_p - if (mode) item.mode = mode - if (color) item.color = color - // just here for consistency & to prevent it from being added as an option - if (name) item.name = name - if (maxSteps != undefined) item.maxSteps = maxSteps - - if (permission ?? cfg.permission) { - item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) - } + if (value.model) item.model = Provider.parseModel(value.model) + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.name = value.options?.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } - - // Mark the default agent - const defaultName = cfg.default_agent ?? "build" - const defaultCandidate = result[defaultName] - if (defaultCandidate && defaultCandidate.mode !== "subagent") { - defaultCandidate.default = true - } else { - // Fall back to "build" if configured default is invalid - if (result["build"]) { - result["build"].default = true - } - } - return result }) @@ -239,12 +184,6 @@ export namespace Agent { return state().then((x) => Object.values(x)) } - export async function defaultAgent(): Promise { - const agents = await state() - const defaultCandidate = Object.values(agents).find((a) => a.default) - return defaultCandidate?.name ?? "build" - } - export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { const cfg = await Config.get() const defaultModel = input.model ?? (await Provider.defaultModel()) @@ -283,42 +222,3 @@ export namespace Agent { return result.object } } - -function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] { - if (typeof basePermission.bash === "string") { - basePermission.bash = { - "*": basePermission.bash, - } - } - if (typeof overridePermission.bash === "string") { - overridePermission.bash = { - "*": overridePermission.bash, - } - } - const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any - let mergedBash - if (merged.bash) { - if (typeof merged.bash === "string") { - mergedBash = { - "*": merged.bash, - } - } else if (typeof merged.bash === "object") { - mergedBash = mergeDeep( - { - "*": "allow", - }, - merged.bash, - ) - } - } - - const result: Agent.Info["permission"] = { - edit: merged.edit ?? "allow", - webfetch: merged.webfetch ?? "allow", - bash: mergedBash ?? { "*": "allow" }, - doom_loop: merged.doom_loop, - external_directory: merged.external_directory, - } - - return result -} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 2e1475e54..d9ed02c5b 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -4,6 +4,7 @@ import { Config } from "@/config/config" import { Identifier } from "@/id/id" import { Instance } from "@/project/instance" import { fn } from "@/util/fn" +import { Wildcard } from "@/util/wildcard" import z from "zod" export namespace PermissionNext { @@ -31,6 +32,24 @@ export namespace PermissionNext { return ruleset } + export function merge(...rulesets: Ruleset[]): Ruleset { + const result: Ruleset = {} + for (const ruleset of rulesets) { + for (const [permission, rule] of Object.entries(ruleset)) { + result[permission] ??= {} + for (const [pattern, action] of Object.entries(rule)) { + for (const existing of Object.keys(result[permission])) { + if (Wildcard.match(existing, pattern)) { + delete result[permission][existing] + } + } + result[permission][pattern] = action + } + } + } + return result + } + export const Request = z .object({ id: Identifier.schema("permission"), @@ -117,14 +136,23 @@ export namespace PermissionNext { }, ) - export const evaluate = fn( - z.object({ - permission: z.string(), - pattern: z.string(), - rules: Config.Permission.array(), - }), - async (input) => {}, - ) + export const Action = z.enum(["allow", "deny", "ask"]) + export type Action = z.infer + + export function evaluate(permission: string, pattern: string, ruleset: Ruleset): Action { + const rule = ruleset[permission] + if (!rule) return "ask" + + let best: { length: number; action: Action } | undefined + for (const [p, action] of Object.entries(rule)) { + if (!Wildcard.match(pattern, p)) continue + if (!best || p.length > best.length) { + best = { length: p.length, action } + } + } + + return best?.action ?? "ask" + } export class RejectedError extends Error { constructor(public readonly reason?: string) { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts new file mode 100644 index 000000000..9cb2b36db --- /dev/null +++ b/packages/opencode/test/permission/next.test.ts @@ -0,0 +1,91 @@ +import { test, expect } from "bun:test" +import { PermissionNext } from "../../src/permission/next" + +// fromConfig tests + +test("fromConfig - string value becomes wildcard", () => { + const result = PermissionNext.fromConfig({ bash: "allow" }) + expect(result).toEqual({ bash: { "*": "allow" } }) +}) + +test("fromConfig - object value stays as-is", () => { + const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) + expect(result).toEqual({ bash: { "*": "allow", rm: "deny" } }) +}) + +test("fromConfig - mixed string and object values", () => { + const result = PermissionNext.fromConfig({ + bash: { "*": "allow", rm: "deny" }, + edit: "allow", + webfetch: "ask", + }) + expect(result).toEqual({ + bash: { "*": "allow", rm: "deny" }, + edit: { "*": "allow" }, + webfetch: { "*": "ask" }, + }) +}) + +test("fromConfig - empty object", () => { + const result = PermissionNext.fromConfig({}) + expect(result).toEqual({}) +}) + +// 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" } }) + expect(result).toEqual({ + bash: { "*": "allow" }, + edit: { "*": "deny" }, + }) +}) + +test("merge - wildcard wipes specific patterns", () => { + const result = PermissionNext.merge({ bash: { foo: "ask", bar: "allow" } }, { bash: { "*": "deny" } }) + expect(result).toEqual({ bash: { "*": "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 wipes matching patterns", () => { + const result = PermissionNext.merge( + { bash: { "foo/bar": "ask", "foo/baz": "allow", other: "deny" } }, + { bash: { "foo/*": "deny" } }, + ) + expect(result).toEqual({ bash: { "foo/*": "deny", other: "deny" } }) +}) + +test("merge - multiple rulesets", () => { + const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { rm: "ask" } }, { edit: { "*": "allow" } }) + expect(result).toEqual({ + bash: { "*": "allow", rm: "ask" }, + edit: { "*": "allow" }, + }) +}) + +test("merge - empty ruleset does nothing", () => { + const result = PermissionNext.merge({ bash: { "*": "allow" } }, {}) + expect(result).toEqual({ bash: { "*": "allow" } }) +}) + +test("merge - nested glob patterns", () => { + const result = PermissionNext.merge( + { edit: { "src/components/Button.tsx": "allow", "src/components/Input.tsx": "allow" } }, + { edit: { "src/components/*": "deny" } }, + ) + expect(result).toEqual({ edit: { "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" } }) +})