From a2bcf8833b6b0b599268f0eef07482d37cc29803 Mon Sep 17 00:00:00 2001
From: iceteaSA <171169159+iceteaSA@users.noreply.github.com>
Date: Sun, 2 Aug 2026 12:29:07 +0200
Subject: [PATCH] fix(opencode): match absolute permission patterns outside
worktree
---
.../opencode/src/project/instance-context.ts | 7 ++
packages/opencode/src/tool/apply_patch.ts | 3 +-
packages/opencode/src/tool/edit.ts | 5 +-
packages/opencode/src/tool/read.ts | 3 +-
packages/opencode/src/tool/write.ts | 3 +-
.../opencode/test/tool/apply_patch.test.ts | 47 ++++++++++++
packages/opencode/test/tool/read.test.ts | 50 +++++++++++++
packages/opencode/test/tool/write.test.ts | 72 +++++++++++++++++++
8 files changed, 185 insertions(+), 5 deletions(-)
diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts
index 18ea39e16e91..b4363286bc89 100644
--- a/packages/opencode/src/project/instance-context.ts
+++ b/packages/opencode/src/project/instance-context.ts
@@ -1,4 +1,5 @@
import { LocalContext } from "@/util/local-context"
+import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type * as Project from "./project"
@@ -22,3 +23,9 @@ export function containsPath(filepath: string, ctx: InstanceContext): boolean {
if (ctx.worktree === "/") return false
return FSUtil.contains(ctx.worktree, filepath)
}
+
+export function permissionPath(filepath: string, ctx: InstanceContext): string {
+ // A root worktree is the non-git sentinel, not a project boundary.
+ if (ctx.worktree === "/" || !containsPath(filepath, ctx)) return filepath
+ return path.relative(ctx.worktree, filepath)
+}
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index f9201be8a7db..f28210a82c61 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -4,6 +4,7 @@ import * as Tool from "./tool"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstanceState } from "@/effect/instance-state"
+import { permissionPath } from "@/project/instance-context"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectoryEffect } from "./external-directory"
@@ -202,7 +203,7 @@ export const ApplyPatchTool = Tool.define(
}))
// Check permissions if needed
- const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/"))
+ const relativePaths = fileChanges.map((c) => permissionPath(c.filePath, instance).replaceAll("\\", "/"))
yield* ctx.ask({
permission: "edit",
patterns: relativePaths,
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index a92e4720c0fc..b1f5c67146a5 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -14,6 +14,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
+import { permissionPath } from "@/project/instance-context"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -101,7 +102,7 @@ export const EditTool = Tool.define(
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
- patterns: [path.relative(instance.worktree, filePath)],
+ patterns: [permissionPath(filePath, instance)],
always: ["*"],
metadata: {
filepath: filePath,
@@ -144,7 +145,7 @@ export const EditTool = Tool.define(
)
yield* ctx.ask({
permission: "edit",
- patterns: [path.relative(instance.worktree, filePath)],
+ patterns: [permissionPath(filePath, instance)],
always: ["*"],
metadata: {
filepath: filePath,
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index 678ed4451048..7adda83b606c 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -6,6 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { LSP } from "@/lsp/lsp"
import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
+import { permissionPath } from "@/project/instance-context"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
@@ -254,7 +255,7 @@ export const ReadTool = Tool.define<
yield* ctx.ask({
permission: "read",
- patterns: [path.relative(instance.worktree, filepath)],
+ patterns: [permissionPath(filepath, instance)],
always: ["*"],
metadata: {},
})
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 37be6d8c47bc..e400a1558974 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -11,6 +11,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Format } from "../format"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
+import { permissionPath } from "@/project/instance-context"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
import * as Bom from "@/util/bom"
@@ -53,7 +54,7 @@ export const WriteTool = Tool.define(
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
- patterns: [path.relative(instance.worktree, filepath)],
+ patterns: [permissionPath(filepath, instance)],
always: ["*"],
metadata: {
filepath,
diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts
index e394d8084f9a..1498578c2b00 100644
--- a/packages/opencode/test/tool/apply_patch.test.ts
+++ b/packages/opencode/test/tool/apply_patch.test.ts
@@ -12,6 +12,8 @@ import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Truncate } from "@/tool/truncate"
import { TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
+import { PermissionV1 } from "@opencode-ai/core/v1/permission"
+import { Permission } from "../../src/permission"
import { testEffect } from "../lib/effect"
const it = testEffect(
@@ -72,6 +74,24 @@ const makeCtx = () => {
return { ctx, calls }
}
+const makeRulesCtx = (ruleset: PermissionV1.Ruleset) => {
+ const calls: AskInput[] = []
+ const ctx: ToolCtx = {
+ ...baseCtx,
+ ask: (input) =>
+ Effect.sync(() => {
+ calls.push(input)
+ if (input.permission === "external_directory") return
+ for (const pattern of input.patterns) {
+ const rule = Permission.evaluate(input.permission, pattern, ruleset)
+ if (rule.action !== "allow") throw new Error(`permission ${rule.action}: ${pattern}`)
+ }
+ }),
+ }
+
+ return { ctx, calls }
+}
+
const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8"))
const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8"))
const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
@@ -85,6 +105,33 @@ const expectFailure = (effect: Effect.Effect, message?: string
const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))
+describe("tool.apply_patch permission paths", () => {
+ it.instance(
+ "checks absolute rules for every outside path in a multi-file patch",
+ () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
+ const first = path.join(outer, "first.txt")
+ const second = path.join(outer, "second.txt")
+ const firstRelative = path.relative(test.directory, first).replaceAll("\\", "/")
+ const secondRelative = path.relative(test.directory, second).replaceAll("\\", "/")
+ const patchText = `*** Begin Patch\n*** Add File: ${firstRelative}\n+first\n*** Add File: ${secondRelative}\n+second\n*** End Patch`
+ const { ctx, calls } = makeRulesCtx(
+ Permission.fromConfig({ edit: { "*": "allow", [`${outer}/**`]: "deny" } }),
+ )
+
+ const exit = yield* execute({ patchText }, ctx).pipe(Effect.exit)
+ expect(exit._tag).toBe("Failure")
+ expect(calls.at(-1)?.patterns).toEqual([first, second].map((file) => file.replaceAll("\\", "/")))
+ expect(yield* Effect.promise(() => fs.stat(first).catch(() => undefined))).toBeUndefined()
+ expect(yield* Effect.promise(() => fs.stat(second).catch(() => undefined))).toBeUndefined()
+ yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
+ }),
+ { git: true },
+ )
+})
+
describe("tool.apply_patch freeform", () => {
it.live("requires patchText", () =>
Effect.gen(function* () {
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index c1ef61b227dd..922e20d45e16 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -3,6 +3,8 @@ import { afterEach, describe, expect } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import path from "path"
+import os from "os"
+import * as fs from "fs/promises"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -148,6 +150,54 @@ const asks = () => {
}
}
+const askWithRules = (ruleset: PermissionV1.Ruleset) => ({
+ ...ctx,
+ ask: (input: Omit) =>
+ Effect.sync(() => {
+ if (input.permission === "external_directory") return
+ for (const pattern of input.patterns) {
+ const rule = Permission.evaluate(input.permission, pattern, ruleset)
+ if (rule.action !== "allow") throw new Error(`permission ${rule.action}: ${pattern}`)
+ }
+ }),
+})
+
+describe("tool.read permission paths", () => {
+ it.live("allows an outside-worktree absolute rule", () =>
+ Effect.gen(function* () {
+ const outer = yield* tmpdirScoped()
+ const dir = yield* tmpdirScoped({ git: true })
+ const filepath = path.join(outer, "allowed.txt")
+ yield* put(filepath, "allowed")
+
+ const result = yield* exec(
+ dir,
+ { filePath: filepath },
+ askWithRules(Permission.fromConfig({ read: { [`${outer}/**`]: "allow" } })),
+ )
+ expect(result.output).toContain("allowed")
+ }),
+ )
+
+ it.live("expands a tilde rule for an outside-worktree home path", () =>
+ Effect.gen(function* () {
+ const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(os.homedir(), "opencode-permission-")))
+ const dir = yield* tmpdirScoped({ git: true })
+ const filepath = path.join(outer, "home-file.txt")
+ yield* put(filepath, "home content")
+
+ const rule = `~/${path.relative(os.homedir(), outer).replaceAll("\\", "/")}/**`
+ const result = yield* exec(
+ dir,
+ { filePath: filepath },
+ askWithRules(Permission.fromConfig({ read: { [rule]: "allow" } })),
+ )
+ expect(result.output).toContain("home content")
+ yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
+ }),
+ )
+})
+
describe("tool.read external_directory permission", () => {
it.live("allows reading absolute path inside project directory", () =>
Effect.gen(function* () {
diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts
index 4f897dc87591..5b1cdca1ad7e 100644
--- a/packages/opencode/test/tool/write.test.ts
+++ b/packages/opencode/test/tool/write.test.ts
@@ -12,6 +12,8 @@ import { Truncate } from "@/tool/truncate"
import { Tool } from "@/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
+import { PermissionV1 } from "@opencode-ai/core/v1/permission"
+import { Permission } from "../../src/permission"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -58,6 +60,76 @@ const run = Effect.fn("WriteToolTest.run")(function* (
return yield* tool.execute(args, next)
})
+const askWithRules = (ruleset: PermissionV1.Ruleset) => ({
+ ...ctx,
+ ask: (input: Parameters[0]) =>
+ Effect.sync(() => {
+ if (input.permission === "external_directory") return
+ for (const pattern of input.patterns) {
+ const rule = Permission.evaluate(input.permission, pattern, ruleset)
+ if (rule.action !== "allow") throw new Error(`permission ${rule.action}: ${pattern}`)
+ }
+ }),
+})
+
+describe("tool.write permission paths", () => {
+ it.instance("matches an inside-worktree relative rule", () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const filepath = path.join(test.directory, "src", "inside.txt")
+ yield* run(
+ { filePath: filepath, content: "inside" },
+ askWithRules(Permission.fromConfig({ edit: { "src/**": "allow" } })),
+ )
+ expect(yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))).toBe("inside")
+ }),
+ { git: true },
+ )
+
+ it.instance("allows an outside-worktree absolute rule", () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
+ const filepath = path.join(outer, "allowed.txt")
+ yield* run(
+ { filePath: filepath, content: "allowed" },
+ askWithRules(Permission.fromConfig({ edit: { [`${outer}/**`]: "allow" } })),
+ )
+ expect(yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))).toBe("allowed")
+ yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
+ }),
+ { git: true },
+ )
+
+ it.instance("enforces an outside-worktree absolute deny over a wildcard allow", () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
+ const filepath = path.join(outer, "denied.txt")
+ const rules = Permission.fromConfig({ edit: { "*": "allow", [`${outer}/**`]: "deny" } })
+ const exit = yield* run({ filePath: filepath, content: "denied" }, askWithRules(rules)).pipe(Effect.exit)
+ expect(exit._tag).toBe("Failure")
+ expect(yield* Effect.promise(() => fs.stat(filepath).catch(() => undefined))).toBeUndefined()
+ yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
+ }),
+ { git: true },
+ )
+
+ it.instance("does not match an outside-worktree relative rule", () =>
+ Effect.gen(function* () {
+ const test = yield* TestInstance
+ const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
+ const filepath = path.join(outer, "not-src.txt")
+ const rules = Permission.fromConfig({ edit: { "src/**": "allow" } })
+ const exit = yield* run({ filePath: filepath, content: "not allowed" }, askWithRules(rules)).pipe(Effect.exit)
+ expect(exit._tag).toBe("Failure")
+ expect(yield* Effect.promise(() => fs.stat(filepath).catch(() => undefined))).toBeUndefined()
+ yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
+ }),
+ { git: true },
+ )
+})
+
describe("tool.write", () => {
describe("new file creation", () => {
it.instance("writes content to new file", () =>