fix(core): preserve agent permission precedence

This commit is contained in:
Aiden Cline 2026-07-09 17:59:44 -05:00
commit e4ef2acb0b
3 changed files with 88 additions and 28 deletions

View file

@ -15,6 +15,7 @@ import { PermissionV2 } from "../../permission"
import type { LocationMutation } from "../../location-mutation" import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read" import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit" import type { EditTool } from "../../tool/edit"
import { AgentPlugin } from "../../plugin/agent"
const legacySources = [ const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false }, { pattern: "{agent,agents}/**/*.md", primary: false },
@ -76,7 +77,22 @@ export const Plugin = define({
const configuredDefault = Config.latest(loaded.documents, "default_agent") const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) { 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) { for (const document of loaded.documents) {

View file

@ -13,6 +13,21 @@ import { PermissionV2 } from "../permission"
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*") const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM = 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." "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. 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) { effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service const location = yield* Location.Service
const worktree = location.directory 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) => { yield* ctx.agent.transform((draft) => {
draft.update(AgentV2.defaultID, (item) => { 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.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
...PermissionV2.merge(defaults, [ ...PermissionV2.merge(defaultPermissions, [
{ action: "question", resource: "*", effect: "allow" }, { action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", 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.description = "Plan mode. Disallows all edit tools."
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
...PermissionV2.merge(defaults, [ ...PermissionV2.merge(defaultPermissions, [
{ action: "question", resource: "*", effect: "allow" }, { action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" }, { action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" }, { action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
@ -161,7 +158,9 @@ export const Plugin = define({
item.description = 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." "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.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) => { draft.update(AgentV2.ID.make("explore"), (item) => {
@ -172,7 +171,7 @@ export const Plugin = define({
item.mode = "subagent" item.mode = "subagent"
item.permissions.push( item.permissions.push(
...PermissionV2.merge( ...PermissionV2.merge(
defaults, defaultPermissions,
[ [
{ action: "*", resource: "*", effect: "deny" }, { action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" }, { action: "grep", resource: "*", effect: "allow" },
@ -192,7 +191,9 @@ export const Plugin = define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_COMPACTION 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) => { draft.update(AgentV2.ID.make("title"), (item) => {
@ -200,7 +201,9 @@ export const Plugin = define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_TITLE 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) => { draft.update(AgentV2.ID.make("summary"), (item) => {
@ -208,7 +211,9 @@ export const Plugin = define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_SUMMARY item.system = PROMPT_SUMMARY
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push(
...PermissionV2.merge(defaultPermissions, [{ action: "*", resource: "*", effect: "deny" }]),
)
}) })
}) })
}), }),

View file

@ -9,9 +9,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir" import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host" 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", () => it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
@ -110,13 +149,13 @@ describe("ConfigAgentPlugin.Plugin", () => {
if (!buildAgent) throw new Error("expected configured build agent") if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([ expect(buildAgent.permissions).toEqual([
...defaultPermissions, ...defaultPermissions,
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" }, { action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" }, { action: "bash", resource: "git *", effect: "allow" },
]) ])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("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")) const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent") if (!reviewer) throw new Error("expected configured reviewer agent")