From 3d2b5660e7bf9cf28b0917b8cb1b92b5a49ba855 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:51:58 +0200 Subject: [PATCH] permissions: match absolute rules for paths outside the project root --- .../src/__tests__/permissions-effect.test.ts | 103 ++++++++++++++++++ packages/opencode-plugin/src/tools/hoisted.ts | 20 +++- .../opencode-plugin/src/tools/permissions.ts | 22 +++- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/packages/opencode-plugin/src/__tests__/permissions-effect.test.ts b/packages/opencode-plugin/src/__tests__/permissions-effect.test.ts index d5696393e..ed91df88e 100644 --- a/packages/opencode-plugin/src/__tests__/permissions-effect.test.ts +++ b/packages/opencode-plugin/src/__tests__/permissions-effect.test.ts @@ -21,13 +21,17 @@ * declares for `ToolContext["ask"]`. */ import { describe, expect, test } from "bun:test"; +import type { BridgePool } from "@cortexkit/aft-bridge"; import type { ToolContext } from "@opencode-ai/plugin"; import { askEditPermission, askGlobPermission, askGrepPermission, + permissionPath, runAsk, } from "../tools/permissions.js"; +import { hoistedTools } from "../tools/hoisted.js"; +import type { PluginContext } from "../types.js"; describe("runAsk + Promise", () => { test("a resolving Promise body actually runs through runAsk (allow path)", async () => { @@ -91,6 +95,105 @@ describe("runAsk + Promise", () => { }); }); +describe("hoisted write permission patterns", () => { + test("passes an absolute outside-root path to askEditPermission", async () => { + let observed: { patterns?: string[] } = {}; + const bridge = { + toolCall: async ( + _sessionID: string | undefined, + _name: string, + _args: Record, + options?: { preview?: boolean }, + ) => + options?.preview === true + ? { success: true, preview: true, preview_diff: "" } + : { success: true, text: "Created new file." }, + }; + const pluginContext = { + pool: { getBridge: () => bridge } as unknown as BridgePool, + client: {}, + config: {}, + storageDir: process.cwd(), + } as unknown as PluginContext; + const context = { + ...makeMockContext(async (input) => { + observed = input as typeof observed; + }), + sessionID: "outside-root-write-permission-test", + directory: process.cwd(), + worktree: process.cwd(), + }; + + await hoistedTools(pluginContext).write.execute( + { path: "/tmp/x", content: "content\n" }, + context, + ); + + expect(observed.patterns).toEqual(["/tmp/x"]); + }); + + test("uses apply_patch absolute affected paths for outside-root permission asks", async () => { + let observed: { patterns?: string[] } = {}; + const bridge = { + toolCall: async ( + _sessionID: string | undefined, + _name: string, + _args: Record, + options?: { preview?: boolean }, + ) => + options?.preview === true + ? { + success: true, + preview: true, + preview_diff: "", + affected_paths: ["/tmp/report.md"], + affected_rel_paths: ["../../../../tmp/report.md"], + } + : { success: true, text: "Applied patch." }, + }; + const pluginContext = { + pool: { getBridge: () => bridge } as unknown as BridgePool, + client: {}, + config: {}, + storageDir: process.cwd(), + } as unknown as PluginContext; + const context = { + ...makeMockContext(async (input) => { + observed = input as typeof observed; + }), + sessionID: "outside-root-apply-patch-permission-test", + directory: process.cwd(), + worktree: process.cwd(), + }; + + await hoistedTools(pluginContext).apply_patch.execute( + { patchText: "*** Begin Patch\n*** End Patch" }, + context, + ); + + expect(observed.patterns).toEqual(["/tmp/report.md"]); + }); +}); + +describe("permissionPath", () => { + test("keeps in-project paths relative and root worktrees absolute", () => { + const projectContext = { + ...makeMockContext(async () => {}), + directory: "/workspace/project", + worktree: "/workspace/project", + }; + const rootContext = { + ...makeMockContext(async () => {}), + directory: "/", + worktree: "/", + }; + + expect(permissionPath(projectContext, "src/foo.ts")).toBe("src/foo.ts"); + expect(permissionPath(projectContext, "/tmp/x")).toBe("/tmp/x"); + expect(permissionPath(rootContext, "/tmp/x")).toBe("/tmp/x"); + }); +}); + describe("askGrepPermission / askGlobPermission (Promise contract)", () => { test("askGrepPermission returns undefined on allow", async () => { const ctx = makeMockContext(async () => {}); diff --git a/packages/opencode-plugin/src/tools/hoisted.ts b/packages/opencode-plugin/src/tools/hoisted.ts index 31f3de1c3..9cb042873 100644 --- a/packages/opencode-plugin/src/tools/hoisted.ts +++ b/packages/opencode-plugin/src/tools/hoisted.ts @@ -29,6 +29,7 @@ import { createBashWriteTool } from "./bash_write.js"; import { askEditPermission, assertExternalDirectoryPermission, + permissionPath, permissionDeniedResponse, runAsk, } from "./permissions.js"; @@ -472,7 +473,7 @@ function createWriteTool(ctx: PluginContext, editToolName = "edit"): ToolDefinit const filePath = resolvePathFromProjectRoot(projectRoot, file); persistFilePathAlias(argsRecord, context); - const relPath = path.relative(projectRoot, filePath); + const permissionPattern = permissionPath(context, filePath); // External-directory check first (mirrors opencode-native write.ts:43). { @@ -487,7 +488,7 @@ function createWriteTool(ctx: PluginContext, editToolName = "edit"): ToolDefinit throw toolErrorFromResponse("write", preview); } - const denial = await askEditPermission(context, [relPath], { + const denial = await askEditPermission(context, [permissionPattern], { filepath: filePath, diff: typeof preview.preview_diff === "string" ? preview.preview_diff : "", }); @@ -675,7 +676,7 @@ function createEditTool(ctx: PluginContext, writeToolName = "write"): ToolDefini const filePath = resolvePathFromProjectRoot(projectRoot, file); persistFilePathAlias(argsRecord, context); - const relPath = path.relative(projectRoot, filePath); + const permissionPattern = permissionPath(context, filePath); // External-directory check first (mirrors opencode-native edit.ts:68). { @@ -693,7 +694,7 @@ function createEditTool(ctx: PluginContext, writeToolName = "write"): ToolDefini throw toolErrorFromResponse("edit", preview); } - const denial = await askEditPermission(context, [relPath], { + const denial = await askEditPermission(context, [permissionPattern], { filepath: filePath, diff: typeof preview.preview_diff === "string" ? preview.preview_diff : "", }); @@ -852,9 +853,16 @@ function createApplyPatchTool(ctx: PluginContext): ToolDefinition { } const affectedRelPaths = stringArray(preview.affected_rel_paths); - const denial = await askEditPermission(context, affectedRelPaths, { + const affectedPaths = stringArray(preview.affected_paths); + const permissionPatterns = ( + affectedPaths.length > 0 ? affectedPaths : affectedRelPaths + ).map((filePath) => permissionPath(context, filePath)); + const denial = await askEditPermission(context, permissionPatterns, { diff: typeof preview.preview_diff === "string" ? preview.preview_diff : "", - filepath: typeof preview.filepath === "string" ? preview.filepath : affectedRelPaths[0], + filepath: + typeof preview.filepath === "string" + ? preview.filepath + : affectedPaths[0] ?? affectedRelPaths[0], }); if (denial) return permissionDeniedResponse(denial); diff --git a/packages/opencode-plugin/src/tools/permissions.ts b/packages/opencode-plugin/src/tools/permissions.ts index cf545e115..761e9bb26 100644 --- a/packages/opencode-plugin/src/tools/permissions.ts +++ b/packages/opencode-plugin/src/tools/permissions.ts @@ -83,15 +83,33 @@ export function resolveAbsolutePath(context: ToolContext, target: string): strin return path.isAbsolute(expanded) ? expanded : path.resolve(projectRootFor(context), expanded); } +export function permissionPath(context: ToolContext, target: string): string { + const projectRoot = path.resolve(projectRootFor(context)); + const absolutePath = path.resolve(resolveAbsolutePath(context, target)); + if (projectRoot === path.parse(projectRoot).root) return absolutePath; + + const relativePath = path.relative(projectRoot, absolutePath); + if ( + relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ) { + return relativePath; + } + + return relativePath === "" ? "." : absolutePath; +} + export function resolveRelativePattern(context: ToolContext, target: string): string { - return path.relative(projectRootFor(context), resolveAbsolutePath(context, target)) || "."; + return permissionPath(context, target); } export function resolveRelativePatternFromAbsolute( context: ToolContext, absolutePath: string, ): string { - return path.relative(projectRootFor(context), absolutePath) || "."; + return permissionPath(context, absolutePath); } export function resolveRelativePatterns(context: ToolContext, targets: string[]): string[] {