1
0
Эх сурвалжийг харах

fix(core): expand home-relative permission paths (#35737)

Dustin Deus 1 сар өмнө
parent
commit
fd9ee435ec

+ 39 - 4
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

+ 76 - 1
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
+  })
+}