From e4ef2acb0bb9350e388e797f930e96de36cd9864 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 9 Jul 2026 17:59:44 -0500 Subject: [PATCH] fix(core): preserve agent permission precedence --- packages/core/src/config/plugin/agent.ts | 18 +++++++- packages/core/src/plugin/agent.ts | 55 +++++++++++++----------- packages/core/test/config/agent.test.ts | 43 +++++++++++++++++- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 0be04bf85ab..9b0ec3d56b4 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -15,6 +15,7 @@ import { PermissionV2 } from "../../permission" import type { LocationMutation } from "../../location-mutation" import type { ReadTool } from "../../tool/read" import type { EditTool } from "../../tool/edit" +import { AgentPlugin } from "../../plugin/agent" const legacySources = [ { pattern: "{agent,agents}/**/*.md", primary: false }, @@ -76,7 +77,22 @@ export const Plugin = define({ const configuredDefault = Config.latest(loaded.documents, "default_agent") if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) for (const current of draft.list()) { - draft.update(current.id, (agent) => agent.permissions.push(...permissions)) + draft.update(current.id, (agent) => { + const initial = AgentV2.Info.empty(AgentV2.ID.make(current.id)).permissions.length + const hasBuiltInDefaults = AgentPlugin.defaultPermissions.every((rule, index) => { + const existing = agent.permissions[initial + index] + return ( + existing?.action === rule.action && + existing.resource === rule.resource && + existing.effect === rule.effect + ) + }) + agent.permissions.splice( + hasBuiltInDefaults ? initial + AgentPlugin.defaultPermissions.length : initial, + 0, + ...permissions, + ) + }) } for (const document of loaded.documents) { diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 8b94e219aea..4f59ec96473 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -13,6 +13,21 @@ import { PermissionV2 } from "../permission" const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*") const BUILD_SYSTEM = "You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions." +const readonlyExternalDirectory: PermissionV2.Ruleset = [ + { action: "external_directory", resource: "*", effect: "ask" }, + { action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "allow" }, + { action: "external_directory", resource: path.join(Global.Path.tmp, "*"), effect: "allow" }, +] +export const defaultPermissions: PermissionV2.Ruleset = [ + ...readonlyExternalDirectory.slice(1), + { action: "question", resource: "*", effect: "deny" }, + { action: "plan_enter", resource: "*", effect: "deny" }, + { action: "plan_exit", resource: "*", effect: "deny" }, + { action: "read", resource: "*", effect: "allow" }, + { action: "read", resource: "*.env", effect: "ask" }, + { action: "read", resource: "*.env.*", effect: "ask" }, + { action: "read", resource: "*.env.example", effect: "allow" }, +] const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. @@ -104,24 +119,6 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory - const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")] - const readonlyExternalDirectory: PermissionV2.Ruleset = [ - { action: "external_directory", resource: "*", effect: "ask" }, - ...whitelistedDirs.map( - (resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }), - ), - ] - const defaults: PermissionV2.Ruleset = [ - { action: "*", resource: "*", effect: "allow" }, - ...readonlyExternalDirectory, - { action: "question", resource: "*", effect: "deny" }, - { action: "plan_enter", resource: "*", effect: "deny" }, - { action: "plan_exit", resource: "*", effect: "deny" }, - { action: "read", resource: "*", effect: "allow" }, - { action: "read", resource: "*.env", effect: "ask" }, - { action: "read", resource: "*.env.*", effect: "ask" }, - { action: "read", resource: "*.env.example", effect: "allow" }, - ] yield* ctx.agent.transform((draft) => { draft.update(AgentV2.defaultID, (item) => { @@ -129,7 +126,7 @@ export const Plugin = define({ item.description = "The default agent. Executes tools based on configured permissions." item.mode = "primary" item.permissions.push( - ...PermissionV2.merge(defaults, [ + ...PermissionV2.merge(defaultPermissions, [ { action: "question", resource: "*", effect: "allow" }, { action: "plan_enter", resource: "*", effect: "allow" }, ]), @@ -141,7 +138,7 @@ export const Plugin = define({ item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( - ...PermissionV2.merge(defaults, [ + ...PermissionV2.merge(defaultPermissions, [ { action: "question", resource: "*", effect: "allow" }, { action: "plan_exit", resource: "*", effect: "allow" }, { action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" }, @@ -161,7 +158,9 @@ export const Plugin = define({ item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }])) + item.permissions.push( + ...PermissionV2.merge(defaultPermissions, [{ action: "subagent", resource: "*", effect: "deny" }]), + ) }) draft.update(AgentV2.ID.make("explore"), (item) => { @@ -172,7 +171,7 @@ export const Plugin = define({ item.mode = "subagent" item.permissions.push( ...PermissionV2.merge( - defaults, + defaultPermissions, [ { action: "*", resource: "*", effect: "deny" }, { action: "grep", resource: "*", effect: "allow" }, @@ -192,7 +191,9 @@ export const Plugin = define({ item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) + item.permissions.push( + ...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]), + ) }) draft.update(AgentV2.ID.make("title"), (item) => { @@ -200,7 +201,9 @@ export const Plugin = define({ item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) + item.permissions.push( + ...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]), + ) }) draft.update(AgentV2.ID.make("summary"), (item) => { @@ -208,7 +211,9 @@ export const Plugin = define({ item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY - item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) + item.permissions.push( + ...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]), + ) }) }) }), diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 4579b1151f7..0dcf261116b 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -9,9 +9,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AbsolutePath } from "@opencode-ai/core/schema" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" +import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { agentHost, host } from "../plugin/host" @@ -51,6 +54,42 @@ describe("ConfigAgentPlugin.Plugin", () => { }), ) + it.effect("applies global permissions before built-in and configured agent rules", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/project") })), + ), + ) + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode( + ConfigMigrateV1.migrate({ + permission: { bash: { "rm*": "ask", "git reset*": "ask" } }, + }), + ), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( + Effect.provideService(Config.Service, config), + ) + + const build = yield* agents.get(AgentV2.defaultID) + const explore = yield* agents.get(AgentV2.ID.make("explore")) + if (!build || !explore) throw new Error("expected built-in agents") + expect(PermissionV2.evaluate("shell", "rm -rf tmp", build.permissions).effect).toBe("ask") + expect(PermissionV2.evaluate("shell", "rm -rf tmp", explore.permissions).effect).toBe("deny") + expect(PermissionV2.evaluate("shell", "ls", explore.permissions).effect).toBe("deny") + }), + ) + it.effect("applies all global permissions before agent-specific permissions", () => Effect.gen(function* () { const agents = yield* AgentV2.Service @@ -110,13 +149,13 @@ describe("ConfigAgentPlugin.Plugin", () => { if (!buildAgent) throw new Error("expected configured build agent") expect(buildAgent.permissions).toEqual([ ...defaultPermissions, - { action: "bash", resource: "*", effect: "allow" }, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, + { action: "bash", resource: "*", effect: "allow" }, { action: "bash", resource: "git *", effect: "allow" }, ]) expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow") - expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask") + expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("allow") const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) if (!reviewer) throw new Error("expected configured reviewer agent")