From 813a2bf996ad62f77f98777173868cba78e2d14e Mon Sep 17 00:00:00 2001 From: Qingyu Li <2310301201@stu.pku.edu.cn> Date: Thu, 5 Mar 2026 13:20:49 +0800 Subject: [PATCH 1/4] fix(core): gate project .opencode tools behind permission prompts --- packages/opencode/src/session/prompt.ts | 1 + packages/opencode/src/tool/registry.ts | 119 +++++++++++++++++++----- 2 files changed, 96 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc98..1a3077c6b54 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -783,6 +783,7 @@ export namespace SessionPrompt { for (const item of await ToolRegistry.tools( { modelID: input.model.api.id, providerID: input.model.providerID }, input.agent, + input.session.id, )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c6d7fbc1e4b..049a64ce1dc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -30,36 +30,104 @@ import { Truncate } from "./truncation" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" import { pathToFileURL } from "url" +import { PermissionNext } from "@/permission/next" +import { Global } from "@/global" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) export const state = Instance.state(async () => { - const custom = [] as Tool.Info[] - - const matches = await Config.directories().then((dirs) => - dirs.flatMap((dir) => - Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), - ), - ) - if (matches.length) await Config.waitForDependencies() - for (const match of matches) { - const namespace = path.basename(match, path.extname(match)) - const mod = await import(pathToFileURL(match).href) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) - } + return { + custom: [] as Tool.Info[], + loaded: false, + loading: undefined as Promise | undefined, } + }) - const plugins = await Plugin.list() - for (const plugin of plugins) { - for (const [id, def] of Object.entries(plugin.tool ?? {})) { - custom.push(fromPlugin(id, def)) - } - } + function project(dir: string) { + const root = path.resolve(Instance.worktree) + const cur = path.resolve(dir) + if (!dir.endsWith(".opencode")) return false + if (!cur.startsWith(root + path.sep)) return false + if (dir === Flag.OPENCODE_CONFIG_DIR) return false + if (dir === Global.Path.config) return false + return cur !== path.join(Global.Path.home, ".opencode") + } - return { custom } - }) + async function load(sessionID?: string) { + const s = await state() + if (s.loaded) return + if (s.loading) return s.loading + + s.loading = Config.directories() + .then((dirs) => + dirs.flatMap((dir) => + Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }).map( + (file) => ({ + dir, + file, + }), + ), + ), + ) + .then(async (entries) => { + if (entries.length) await Config.waitForDependencies() + const gate = new Map() + + for (const item of entries) { + if (project(item.dir)) { + const ok = gate.get(item.dir) + if (ok === false) continue + if (ok !== true) { + if (!sessionID) { + log.warn("skipping project custom tools", { + path: item.dir, + reason: "no active session for permission prompt", + }) + gate.set(item.dir, false) + continue + } + const err = await PermissionNext.ask({ + permission: ".opencode", + patterns: [item.dir], + always: [item.dir], + sessionID, + metadata: { + path: item.dir, + file: item.file, + }, + ruleset: [], + }).catch((x) => x) + if (err instanceof Error) { + log.warn("project custom tools denied", { path: item.dir }) + gate.set(item.dir, false) + continue + } + gate.set(item.dir, true) + } + } + + const namespace = path.basename(item.file, path.extname(item.file)) + const mod = await import(pathToFileURL(item.file).href) + for (const [id, def] of Object.entries(mod)) { + s.custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + } + } + + const plugins = await Plugin.list() + for (const plugin of plugins) { + for (const [id, def] of Object.entries(plugin.tool ?? {})) { + s.custom.push(fromPlugin(id, def)) + } + } + s.loaded = true + }) + .finally(() => { + s.loading = undefined + }) + + return s.loading + } function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { @@ -86,6 +154,7 @@ export namespace ToolRegistry { } export async function register(tool: Tool.Info) { + await load() const { custom } = await state() const idx = custom.findIndex((t) => t.id === tool.id) if (idx >= 0) { @@ -95,7 +164,8 @@ export namespace ToolRegistry { custom.push(tool) } - async function all(): Promise { + async function all(sessionID?: string): Promise { + await load(sessionID) const custom = await state().then((x) => x.custom) const config = await Config.get() const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL @@ -134,8 +204,9 @@ export namespace ToolRegistry { modelID: string }, agent?: Agent.Info, + sessionID?: string, ) { - const tools = await all() + const tools = await all(sessionID) const result = await Promise.all( tools .filter((t) => { From ff85419605e1a21da70fc191faab832b7f79ee96 Mon Sep 17 00:00:00 2001 From: Qingyu Li <2310301201@stu.pku.edu.cn> Date: Thu, 5 Mar 2026 13:43:13 +0800 Subject: [PATCH 2/4] chore: trigger compliance checks From d48103bc13c890988df3cdbc652b73cb65f33672 Mon Sep 17 00:00:00 2001 From: Qingyu Li <2310301201@stu.pku.edu.cn> Date: Thu, 5 Mar 2026 14:48:34 +0800 Subject: [PATCH 3/4] fix(cli): show .opencode location in permission prompts --- packages/opencode/src/cli/cmd/run.ts | 5 +++-- .../cli/cmd/tui/routes/session/permission.tsx | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 61bc609bb7c..25538e2d1df 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -539,10 +539,11 @@ export const RunCommand = cmd({ if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue + const pattern = permission.patterns.join(", ") + const desc = permission.permission === ".opencode" ? `.opencode location: ${pattern}` : pattern UI.println( UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${desc}); auto-rejecting`, ) await sdk.permission.reply({ requestID: permission.id, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc84..79fc25c7c07 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -386,6 +386,28 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } + if (permission === ".opencode") { + const meta = props.request.metadata ?? {} + const raw = typeof meta["path"] === "string" ? meta["path"] : props.request.patterns?.[0] + const dir = normalizePath(raw) + const patterns = (props.request.patterns ?? []).filter((x): x is string => typeof x === "string") + return { + icon: "◉", + title: `Load project .opencode from ${dir}`, + body: ( + + {"Path: " + dir} + 0}> + + Patterns + {(p) => {"- " + p}} + + + + ), + } + } + if (permission === "doom_loop") { return { icon: "⟳", From 42b1007e90d4e03b3a1d75c60504781bf1a105b6 Mon Sep 17 00:00:00 2001 From: Qingyu Li <2310301201@stu.pku.edu.cn> Date: Thu, 5 Mar 2026 15:06:14 +0800 Subject: [PATCH 4/4] fix(cli): simplify .opencode permission location messaging --- packages/opencode/src/cli/cmd/run.ts | 7 +++++-- .../cli/cmd/tui/routes/session/permission.tsx | 21 +++++++------------ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 25538e2d1df..37994328f8e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -539,8 +539,11 @@ export const RunCommand = cmd({ if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue - const pattern = permission.patterns.join(", ") - const desc = permission.permission === ".opencode" ? `.opencode location: ${pattern}` : pattern + const patterns = permission.patterns + const desc = + permission.permission === ".opencode" + ? `.opencode location: ${patterns.map((x) => path.resolve(x)).join(", ")}` + : patterns.join(", ") UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${desc}); auto-rejecting`, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 79fc25c7c07..6b1cf98f108 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -368,7 +368,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const raw = parent ?? filepath ?? derived const dir = normalizePath(raw) - const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") + const patterns = (props.request.patterns ?? []).filter((p: unknown): p is string => typeof p === "string") return { icon: "←", @@ -378,7 +378,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { Patterns - {(p) => {"- " + p}} + {(p: string) => {"- " + p}} @@ -389,20 +389,13 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { if (permission === ".opencode") { const meta = props.request.metadata ?? {} const raw = typeof meta["path"] === "string" ? meta["path"] : props.request.patterns?.[0] - const dir = normalizePath(raw) - const patterns = (props.request.patterns ?? []).filter((x): x is string => typeof x === "string") + const dir = raw ? path.resolve(raw) : "" return { - icon: "◉", - title: `Load project .opencode from ${dir}`, + icon: "⚙", + title: "Allow .opencode config", body: ( - - {"Path: " + dir} - 0}> - - Patterns - {(p) => {"- " + p}} - - + + {dir} ), }