From fd9ee435ec3a45e03e400b8a9eb4b50e407addc2 Mon Sep 17 00:00:00 2001 From: Dustin Deus Date: Tue, 7 Jul 2026 16:23:50 +0200 Subject: [PATCH] fix(core): expand home-relative permission paths (#35737) --- packages/core/src/config/plugin/agent.ts | 43 +++++++++++-- packages/core/test/config/agent.test.ts | 77 +++++++++++++++++++++++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe75804..5e0f712732 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -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 }, @@ -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", @@ -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) => { @@ -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) { @@ -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 } @@ -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)) + } }) } } @@ -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 diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 4a28fb7877..1c0f911839 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -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 @@ -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 + }) +}