fix(core): expand home-relative permission paths (#35737)
This commit is contained in:
parent
1c25b2f298
commit
fd9ee435ec
2 changed files with 115 additions and 5 deletions
|
|
@ -11,6 +11,11 @@ import { FSUtil } from "../../fs-util"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
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 = [
|
const legacySources = [
|
||||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||||
|
|
@ -19,6 +24,11 @@ const legacySources = [
|
||||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||||
const decodeConfig = Schema.decodeUnknownOption(Config.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([
|
const agentKeys = new Set([
|
||||||
"model",
|
"model",
|
||||||
"variant",
|
"variant",
|
||||||
|
|
@ -38,6 +48,7 @@ export const Plugin = define({
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
yield* ctx.agent.transform(
|
yield* ctx.agent.transform(
|
||||||
Effect.fn(function* (draft) {
|
Effect.fn(function* (draft) {
|
||||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||||
|
|
@ -56,11 +67,14 @@ export const Plugin = define({
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}).pipe(Effect.map((documents) => documents.flat()))
|
}).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")
|
const configuredDefault = Config.latest(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(...global))
|
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
|
|
@ -73,7 +87,7 @@ export const Plugin = define({
|
||||||
|
|
||||||
const exists = draft.get(agentID) !== undefined
|
const exists = draft.get(agentID) !== undefined
|
||||||
draft.update(agentID, (agent) => {
|
draft.update(agentID, (agent) => {
|
||||||
if (!exists) agent.permissions.push(...global)
|
if (!exists) agent.permissions.push(...permissions)
|
||||||
if (item.model !== undefined) {
|
if (item.model !== undefined) {
|
||||||
const model = ModelV2.parse(item.model)
|
const model = ModelV2.parse(item.model)
|
||||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
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.hidden !== undefined) agent.hidden = item.hidden
|
||||||
if (item.color !== undefined) agent.color = item.color
|
if (item.color !== undefined) agent.color = item.color
|
||||||
if (item.steps !== undefined) agent.steps = item.steps
|
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) {
|
function discover(fs: FSUtil.Interface, directory: string) {
|
||||||
return Effect.forEach(legacySources, (source) =>
|
return Effect.forEach(legacySources, (source) =>
|
||||||
fs
|
fs
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,43 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
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 { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||||
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"
|
||||||
|
|
||||||
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)
|
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||||
|
|
||||||
describe("ConfigAgentPlugin.Plugin", () => {
|
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", () =>
|
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
|
||||||
|
|
@ -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…
Add table
Add a link
Reference in a new issue