Skip to content

Commit fd9ee43

Browse files
authored
fix(core): expand home-relative permission paths (anomalyco#35737)
1 parent 1c25b2f commit fd9ee43

2 files changed

Lines changed: 115 additions & 5 deletions

File tree

packages/core/src/config/plugin/agent.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import { FSUtil } from "../../fs-util"
1111
import { ModelV2 } from "../../model"
1212
import { ConfigAgentV1 } from "../../v1/config/agent"
1313
import { ConfigMigrateV1 } from "../../v1/config/migrate"
14+
import { Global } from "../../global"
15+
import { PermissionV2 } from "../../permission"
16+
import type { LocationMutation } from "../../location-mutation"
17+
import type { ReadTool } from "../../tool/read"
18+
import type { EditTool } from "../../tool/edit"
1419

1520
const legacySources = [
1621
{ pattern: "{agent,agents}/**/*.md", primary: false },
@@ -19,6 +24,11 @@ const legacySources = [
1924
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
2025
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
2126
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
27+
type PathAction =
28+
| LocationMutation.ExternalDirectoryAuthorization["action"]
29+
| typeof ReadTool.name
30+
| typeof EditTool.name
31+
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
2232
const agentKeys = new Set([
2333
"model",
2434
"variant",
@@ -38,6 +48,7 @@ export const Plugin = define({
3848
effect: Effect.fn(function* (ctx) {
3949
const config = yield* Config.Service
4050
const fs = yield* FSUtil.Service
51+
const global = yield* Global.Service
4152
yield* ctx.agent.transform(
4253
Effect.fn(function* (draft) {
4354
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
@@ -56,11 +67,14 @@ export const Plugin = define({
5667
)
5768
})
5869
}).pipe(Effect.map((documents) => documents.flat()))
59-
const global = documents.flatMap((document) => document.info.permissions ?? [])
70+
const permissions = expandPermissions(
71+
documents.flatMap((document) => document.info.permissions ?? []),
72+
global.home,
73+
)
6074
const configuredDefault = Config.latest(documents, "default_agent")
6175
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
6276
for (const current of draft.list()) {
63-
draft.update(current.id, (agent) => agent.permissions.push(...global))
77+
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
6478
}
6579

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

7488
const exists = draft.get(agentID) !== undefined
7589
draft.update(agentID, (agent) => {
76-
if (!exists) agent.permissions.push(...global)
90+
if (!exists) agent.permissions.push(...permissions)
7791
if (item.model !== undefined) {
7892
const model = ModelV2.parse(item.model)
7993
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
@@ -91,7 +105,9 @@ export const Plugin = define({
91105
if (item.hidden !== undefined) agent.hidden = item.hidden
92106
if (item.color !== undefined) agent.color = item.color
93107
if (item.steps !== undefined) agent.steps = item.steps
94-
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
108+
if (item.permissions !== undefined) {
109+
agent.permissions.push(...expandPermissions(item.permissions, global.home))
110+
}
95111
})
96112
}
97113
}
@@ -100,6 +116,25 @@ export const Plugin = define({
100116
}),
101117
})
102118

119+
function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
120+
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
121+
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
122+
return rules.map((rule) => (isPathAction(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule))
123+
}
124+
125+
function isPathAction(action: string): action is PathAction {
126+
return pathActions.some((item) => item === action)
127+
}
128+
129+
function expandHome(resource: string, home: string) {
130+
if (resource.startsWith("~/")) return home + resource.slice(1)
131+
if (resource === "~") return home
132+
if (resource === "$HOME") return home
133+
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
134+
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
135+
return resource
136+
}
137+
103138
function discover(fs: FSUtil.Interface, directory: string) {
104139
return Effect.forEach(legacySources, (source) =>
105140
fs

packages/core/test/config/agent.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,43 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
88
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
99
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
1010
import { FSUtil } from "@opencode-ai/core/fs-util"
11+
import { Global } from "@opencode-ai/core/global"
1112
import { PermissionV2 } from "@opencode-ai/core/permission"
1213
import { AbsolutePath } from "@opencode-ai/core/schema"
14+
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
1315
import { tmpdir } from "../fixture/tmpdir"
1416
import { testEffect } from "../lib/effect"
1517
import { agentHost, host } from "../plugin/host"
1618

17-
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
19+
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
1820
const decode = Schema.decodeUnknownSync(Config.Info)
1921

2022
describe("ConfigAgentPlugin.Plugin", () => {
23+
it.effect("matches POSIX paths against home-relative permissions", () =>
24+
Effect.gen(function* () {
25+
const permissions = yield* loadHomePermissions("/home/test")
26+
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
27+
"allow",
28+
)
29+
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
30+
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
31+
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
32+
expect(PermissionV2.evaluate("bash", "$HOME/private/key", permissions).effect).toBe("deny")
33+
}),
34+
)
35+
36+
it.effect("matches Windows paths against home-relative permissions", () =>
37+
Effect.gen(function* () {
38+
const permissions = yield* loadHomePermissions("C:\\Users\\test")
39+
expect(
40+
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
41+
).toBe("allow")
42+
expect(
43+
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect,
44+
).toBe("deny")
45+
}),
46+
)
47+
2148
it.effect("applies all global permissions before agent-specific permissions", () =>
2249
Effect.gen(function* () {
2350
const agents = yield* AgentV2.Service
@@ -272,3 +299,51 @@ Use native v2 fields.`,
272299
),
273300
)
274301
})
302+
303+
function loadHomePermissions(home: string) {
304+
return Effect.gen(function* () {
305+
const agents = yield* AgentV2.Service
306+
const build = AgentV2.ID.make("build")
307+
yield* agents.transform((editor) => editor.update(build, () => {}))
308+
const config = Config.Service.of({
309+
entries: () =>
310+
Effect.succeed([
311+
new Config.Document({
312+
type: "document",
313+
info: decode(
314+
ConfigMigrateV1.migrate({
315+
permission: {
316+
external_directory: {
317+
"~/p/**": "allow",
318+
"/some/~/path": "deny",
319+
"$HOMELESS/**": "deny",
320+
},
321+
bash: {
322+
"$HOME/private/**": "deny",
323+
},
324+
},
325+
agent: {
326+
build: {
327+
permission: {
328+
external_directory: {
329+
"$HOME/cache/**": "deny",
330+
},
331+
},
332+
},
333+
},
334+
}),
335+
),
336+
}),
337+
]),
338+
})
339+
340+
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
341+
Effect.provideService(Config.Service, config),
342+
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
343+
)
344+
345+
const agent = yield* agents.get(build)
346+
if (!agent) throw new Error("expected configured build agent")
347+
return agent.permissions
348+
})
349+
}

0 commit comments

Comments
 (0)