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", () =>