fix(core): preserve agent permission precedence

This commit is contained in:
Aiden Cline 2026-07-09 17:59:44 -05:00
parent 629b304c48
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 { 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) {

View file

@ -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" }]),
)
})
})
}),

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 { 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")