From 81b3672e6d2aa9d6905755d3834482074715ab8a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:50:06 +1000 Subject: [PATCH 1/3] fix: avoid permission cycle in truncate effect Move permission rule evaluation into a leaf helper so truncate effect can check task access without importing the runtime-backed permission module. --- packages/opencode/src/permission/evaluate.ts | 15 +++++++++++++++ packages/opencode/src/permission/index.ts | 9 +++------ packages/opencode/src/tool/truncate-effect.ts | 4 ++-- 3 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/permission/evaluate.ts diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts new file mode 100644 index 00000000000..2b0604f4bac --- /dev/null +++ b/packages/opencode/src/permission/evaluate.ts @@ -0,0 +1,15 @@ +import { Wildcard } from "@/util/wildcard" + +type Rule = { + permission: string + pattern: string + action: "allow" | "deny" | "ask" +} + +export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule { + const rules = rulesets.flat() + const match = rules.findLast( + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + ) + return match ?? { action: "ask", permission, pattern: "*" } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 93a8c49b655..321c5c374e3 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import os from "os" import z from "zod" +import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" export namespace PermissionNext { @@ -125,12 +126,8 @@ export namespace PermissionNext { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - const rules = rulesets.flat() - log.info("evaluate", { permission, pattern, ruleset: rules }) - const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } + log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) + return evalRule(permission, pattern, ...rulesets) } export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index 4431c18f839..a263cd29437 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -3,7 +3,7 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" import path from "path" import type { Agent } from "../agent/agent" import { AppFileSystem } from "@/filesystem" -import { PermissionNext } from "../permission" +import { evaluate } from "@/permission/evaluate" import { Identifier } from "../id/id" import { Log } from "../util/log" import { ToolID } from "./schema" @@ -28,7 +28,7 @@ export namespace TruncateEffect { function hasTaskTool(agent?: Agent.Info) { if (!agent?.permission) return false - return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny" + return evaluate("task", "*", agent.permission).action !== "deny" } export interface Interface { From d35bac16d5cb07e1f53c2522569209a0ad0ae867 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:51:26 +1000 Subject: [PATCH 2/3] test: cover truncate circular import regression Run truncation through a fresh Bun process so the suite fails if the truncate, permission, and runtime modules form a cycle again. --- .../opencode/test/tool/truncation.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 71439f76049..41d351d0f18 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -4,12 +4,18 @@ import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool/truncate" import { TruncateEffect } from "../../src/tool/truncate-effect" import { Identifier } from "../../src/id/id" +import { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") +const ROOT = path.resolve(import.meta.dir, "..", "..") + +function bun(script: string) { + return [process.execPath, "-e", script] +} describe("Truncate", () => { describe("output", () => { @@ -125,6 +131,19 @@ describe("Truncate", () => { if (result.truncated) throw new Error("expected not truncated") expect("outputPath" in result).toBe(false) }) + + test("loads truncate output in a fresh process", async () => { + const script = [ + 'const { Truncate } = await import("./src/tool/truncate.ts")', + 'const { runtime } = await import("./src/effect/runtime.ts")', + 'const out = await Truncate.output("ok")', + 'if (out.truncated || out.content !== "ok") throw new Error("unexpected truncate result")', + "await runtime.dispose()", + ].join(";") + + const out = await Process.run(bun(script), { cwd: ROOT }) + expect(out.code).toBe(0) + }) }) describe("cleanup", () => { From 712c1f9281be5cea0e1cd86203bcb1044400057a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:05:12 +1000 Subject: [PATCH 3/3] test: exercise truncate cycle in fresh process Load the real truncate-effect module in a fresh Bun process so the regression fails when the permission/runtime circular dependency is reintroduced. --- .../opencode/test/tool/truncation.test.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 41d351d0f18..a00e07e6924 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -13,10 +13,6 @@ import { writeFileStringScoped } from "../lib/filesystem" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") -function bun(script: string) { - return [process.execPath, "-e", script] -} - describe("Truncate", () => { describe("output", () => { test("truncates large json file by bytes", async () => { @@ -132,18 +128,13 @@ describe("Truncate", () => { expect("outputPath" in result).toBe(false) }) - test("loads truncate output in a fresh process", async () => { - const script = [ - 'const { Truncate } = await import("./src/tool/truncate.ts")', - 'const { runtime } = await import("./src/effect/runtime.ts")', - 'const out = await Truncate.output("ok")', - 'if (out.truncated || out.content !== "ok") throw new Error("unexpected truncate result")', - "await runtime.dispose()", - ].join(";") + test("loads truncate effect in a fresh process", async () => { + const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], { + cwd: ROOT, + }) - const out = await Process.run(bun(script), { cwd: ROOT }) expect(out.code).toBe(0) - }) + }, 20000) }) describe("cleanup", () => {