fix(core): preserve agent permission precedence
This commit is contained in:
parent
629b304c48
commit
e4ef2acb0b
3 changed files with 88 additions and 28 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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" }]),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue