From 415e9d24e9efcc952377cb7c55ad76ecdb27b18b Mon Sep 17 00:00:00 2001 From: Ronny Date: Tue, 8 Sep 2026 13:42:42 +0200 Subject: [PATCH] fix(opencode): normalise windows permission path patterns through realpath --- packages/opencode/src/permission/index.ts | 16 +++++++---- .../opencode/test/permission/expand.test.ts | 27 +++++++++++++++++++ .../opencode/test/permission/next.test.ts | 12 ++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/permission/expand.test.ts diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 2e27ff2424db..68b87ffe71da 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -4,6 +4,7 @@ import { InstanceState } from "@/effect/instance-state" import { Wildcard } from "@opencode-ai/core/util/wildcard" import { Deferred, Effect, Layer, Context } from "effect" import os from "os" +import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" @@ -175,11 +176,16 @@ const layer = Layer.effect( }), ) -function expand(pattern: string): string { - if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) - if (pattern === "~") return os.homedir() - if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) - if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) +export function expand(pattern: string): string { + if (pattern.startsWith("~/")) pattern = os.homedir() + pattern.slice(1) + if (pattern === "~") pattern = os.homedir() + if (pattern.startsWith("$HOME/")) pattern = os.homedir() + pattern.slice(5) + if (pattern.startsWith("$HOME")) pattern = os.homedir() + pattern.slice(5) + // On Windows, normalise absolute path patterns through the same realpath pass + // the runtime applies to ask targets. Otherwise whitelist entries written as + // symlinked/junction paths (e.g. ~/.claude resolving to ~/.codex) never match + // the canonicalised request and every external read prompts anyway. + if (process.platform === "win32" && /^[A-Za-z]:[\\/]/.test(pattern)) return FSUtil.normalizePathPattern(pattern) return pattern } diff --git a/packages/opencode/test/permission/expand.test.ts b/packages/opencode/test/permission/expand.test.ts new file mode 100644 index 000000000000..d3447bd15914 --- /dev/null +++ b/packages/opencode/test/permission/expand.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { Permission } from "../../src/permission" + +describe("Permission.expand", () => { + test("keeps non-path permission patterns untouched", () => { + expect(Permission.expand("ls")).toBe("ls") + expect(Permission.expand("*.env")).toBe("*.env") + expect(Permission.expand("src/index.ts")).toBe("src/index.ts") + }) + + test("expands tilde and $HOME to the home directory", () => { + const home = process.env.HOME ?? process.env.USERPROFILE ?? "" + expect(Permission.expand("~/foo").startsWith(home)).toBe(true) + expect(Permission.expand("$HOME/foo").startsWith(home)).toBe(true) + expect(Permission.expand("~/foo")).toContain("foo") + }) + + if (process.platform === "win32") { + test("normalises a windows drive whitelist path via realpath (junction-resolving)", () => { + const resolved = Permission.expand("C:/Users/test/.claude/skills/skill/*") + // Either the real path resolves (junction) or it stays as given — the drive + // prefix and trailing wildcard must survive. + expect(resolved).toMatch(/^[A-Za-z]:[\\/]/) + expect(resolved.replaceAll("\\", "/").endsWith("*")).toBe(true) + }) + } +}) \ No newline at end of file diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 2f2ce98efb34..534f5f98e98c 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -4,6 +4,7 @@ import os from "os" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" import { Permission } from "../../src/permission" import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" @@ -110,17 +111,22 @@ test("fromConfig - empty object", () => { test("fromConfig - expands tilde to home directory", () => { const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) + const expected = + process.platform === "win32" ? FSUtil.normalizePathPattern(`${os.homedir()}/projects/*`) : `${os.homedir()}/projects/*` + expect(result).toEqual([{ permission: "external_directory", pattern: expected, action: "allow" }]) }) test("fromConfig - expands $HOME to home directory", () => { const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) + const expected = + process.platform === "win32" ? FSUtil.normalizePathPattern(`${os.homedir()}/projects/*`) : `${os.homedir()}/projects/*` + expect(result).toEqual([{ permission: "external_directory", pattern: expected, action: "allow" }]) }) test("fromConfig - expands $HOME without trailing slash", () => { const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) + const expected = process.platform === "win32" ? FSUtil.normalizePath(`${os.homedir()}`) : os.homedir() + expect(result).toEqual([{ permission: "external_directory", pattern: expected, action: "allow" }]) }) test("fromConfig - does not expand tilde in middle of path", () => {