From 310a4f751f6136f0cfb1046e8c9ea850a4a88611 Mon Sep 17 00:00:00 2001 From: starptech Date: Tue, 7 Jul 2026 15:37:40 +0200 Subject: [PATCH 1/4] fix(core): expand home-relative permission paths --- packages/core/src/config/plugin/agent.ts | 33 ++++++++-- packages/core/test/config/agent.test.ts | 77 +++++++++++++++++++++++- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe758047a..0255eb0892c1 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -11,6 +11,8 @@ 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" const legacySources = [ { pattern: "{agent,agents}/**/*.md", primary: false }, @@ -38,6 +40,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 +59,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 +79,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 +97,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 +108,23 @@ export const Plugin = define({ }), }) +function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset { + // Only expand resources resolved as paths; rewriting raw commands can make deny rules stop matching. + const pathActions = new Set(["external_directory", "read", "edit"]) + return rules.map((rule) => + pathActions.has(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule, + ) +} + +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 4a28fb7877b4..1c0f9118391f 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 + }) +} From 3398333d0f894947944f23bfec0deb04204b56dd Mon Sep 17 00:00:00 2001 From: starptech Date: Tue, 7 Jul 2026 15:42:13 +0200 Subject: [PATCH 2/4] docs(core): clarify permission path expansion --- packages/core/src/config/plugin/agent.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 0255eb0892c1..d9a692166c1c 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -109,7 +109,8 @@ export const Plugin = define({ }) function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset { - // Only expand resources resolved as paths; rewriting raw commands can make deny rules stop matching. + // Bash permissions match raw commands, so changing a deny from `$HOME/private/**` + // to `/Users/me/private/**` would let the raw command `$HOME/private/key` bypass it. const pathActions = new Set(["external_directory", "read", "edit"]) return rules.map((rule) => pathActions.has(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule, From f96306818910b1c9dfecb72ef86c58fad59a152c Mon Sep 17 00:00:00 2001 From: starptech Date: Tue, 7 Jul 2026 15:51:19 +0200 Subject: [PATCH 3/4] docs(core): clarify permission expansion comment --- packages/core/src/config/plugin/agent.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index d9a692166c1c..69ae263c109e 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -109,8 +109,8 @@ export const Plugin = define({ }) function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset { - // Bash permissions match raw commands, so changing a deny from `$HOME/private/**` - // to `/Users/me/private/**` would let the raw command `$HOME/private/key` bypass it. + // 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. const pathActions = new Set(["external_directory", "read", "edit"]) return rules.map((rule) => pathActions.has(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule, From 7a6154fb88601753f640d89238803983cbb8fd59 Mon Sep 17 00:00:00 2001 From: starptech Date: Tue, 7 Jul 2026 15:57:46 +0200 Subject: [PATCH 4/4] refactor(core): extract path action guard --- packages/core/src/config/plugin/agent.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 69ae263c109e..5e0f712732d8 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -13,6 +13,9 @@ 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 }, @@ -21,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", @@ -111,10 +119,11 @@ 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. - const pathActions = new Set(["external_directory", "read", "edit"]) - return rules.map((rule) => - pathActions.has(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule, - ) + 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) {