Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions packages/core/src/config/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "../../global"
import { PermissionV2 } from "../../permission"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"

const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
Expand All @@ -19,6 +24,11 @@ const legacySources = [
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set([
"model",
"variant",
Expand All @@ -38,6 +48,7 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
Expand All @@ -56,11 +67,14 @@ export const Plugin = define({
)
})
}).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? [])
const permissions = expandPermissions(
documents.flatMap((document) => document.info.permissions ?? []),
global.home,
)
const configuredDefault = Config.latest(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(...global))
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}

for (const document of documents) {
Expand All @@ -73,7 +87,7 @@ export const Plugin = define({

const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (!exists) agent.permissions.push(...permissions)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
Expand All @@ -91,7 +105,9 @@ export const Plugin = define({
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
if (item.permissions !== undefined) {
agent.permissions.push(...expandPermissions(item.permissions, global.home))
}
})
}
}
Expand All @@ -100,6 +116,25 @@ export const Plugin = define({
}),
})

function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
return rules.map((rule) => (isPathAction(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule))
}

function isPathAction(action: string): action is PathAction {
return pathActions.some((item) => item === action)
}

function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home
if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
return resource
}

function discover(fs: FSUtil.Interface, directory: string) {
return Effect.forEach(legacySources, (source) =>
fs
Expand Down
77 changes: 76 additions & 1 deletion packages/core/test/config/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,43 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
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 { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"

const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Config.Info)

describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
"allow",
)
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("bash", "$HOME/private/key", permissions).effect).toBe("deny")
}),
)

it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
expect(
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect,
).toBe("deny")
}),
)

it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
Expand Down Expand Up @@ -272,3 +299,51 @@ Use native v2 fields.`,
),
)
})

function loadHomePermissions(home: string) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
external_directory: {
"~/p/**": "allow",
"/some/~/path": "deny",
"$HOMELESS/**": "deny",
},
bash: {
"$HOME/private/**": "deny",
},
},
agent: {
build: {
permission: {
external_directory: {
"$HOME/cache/**": "deny",
},
},
},
},
}),
),
}),
]),
})

yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
)

const agent = yield* agents.get(build)
if (!agent) throw new Error("expected configured build agent")
return agent.permissions
})
}
Loading