diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 26a5a472a1..d0d4d80f2b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -16,11 +16,13 @@ export namespace Agent { builtIn: z.boolean(), topP: z.number().optional(), temperature: z.number().optional(), - permission: z.object({ - edit: Config.Permission, - bash: z.record(z.string(), Config.Permission), - webfetch: Config.Permission.optional(), - }), + permission: z + .object({ + edit: Config.Permission.optional(), + bash: z.union([Config.Permission, z.record(z.string(), Config.Permission)]).optional(), + webfetch: Config.Permission.optional(), + }) + .catchall(z.union([Config.Permission, z.record(z.string(), Config.Permission)])), model: z .object({ modelID: z.string(), @@ -45,6 +47,7 @@ export namespace Agent { "*": "allow", }, webfetch: "allow", + "*": "ask", // Default for all tools } const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) @@ -53,6 +56,7 @@ export namespace Agent { edit: "deny", bash: "ask", webfetch: "allow", + "*": "ask", // Default for all tools }, cfg.permission ?? {}, ) @@ -175,7 +179,11 @@ export namespace Agent { } } -function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] { +function mergeAgentPermissions( + basePermission: Partial, + overridePermission: Partial, +): Agent.Info["permission"] { + // Handle bash permission normalization if (typeof basePermission.bash === "string") { basePermission.bash = { "*": basePermission.bash, @@ -186,7 +194,10 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag "*": overridePermission.bash, } } - const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any + + const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) + + // Handle bash merging let mergedBash if (merged.bash) { if (typeof merged.bash === "string") { @@ -207,6 +218,10 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag edit: merged.edit ?? "allow", webfetch: merged.webfetch ?? "allow", bash: mergedBash ?? { "*": "allow" }, + // All other keys are tool permissions - they get merged automatically via mergeDeep + ...Object.fromEntries( + Object.entries(merged).filter(([key]) => !["edit", "webfetch", "bash"].includes(key)) + ), } return result diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2372404187..c4901f9522 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -324,6 +324,7 @@ export namespace Config { bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), }) + .catchall(Permission) .optional(), }) .catchall(z.any()) @@ -555,6 +556,7 @@ export namespace Config { bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), }) + .catchall(Permission) .optional(), tools: z.record(z.string(), z.boolean()).optional(), experimental: z diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fa9d7f6336..167a424efe 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -518,6 +518,37 @@ export namespace SessionPrompt { const execute = item.execute if (!execute) continue item.execute = async (args, opts) => { + // Check tool permissions using wildcard matching directly on permission object + // Exclude known non-tool fields + const toolPermissions = Object.fromEntries( + Object.entries(input.agent.permission).filter( + ([k]) => !["edit", "bash", "webfetch"].includes(k) + ) + ) + + const permission = Wildcard.all(key, toolPermissions) + + if (permission === "deny") { + throw new Error( + `The user has specifically restricted access to this tool, you are not allowed to execute it. Tool: ${key}`, + ) + } + + if (permission === "ask") { + await Permission.ask({ + type: "tool", + pattern: key, + sessionID: input.sessionID, + messageID: input.processor.message.id, + callID: opts.toolCallId, + title: `Use tool "${key}"`, + metadata: { + tool: key, + args, + }, + }) + } + await Plugin.trigger( "tool.execute.before", { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 2e456c8b33..750d0276b5 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -57,7 +57,10 @@ export const BashTool = Tool.define("bash", { async execute(params, ctx) { const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) const tree = await parser().then((p) => p.parse(params.command)) - const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash) + const bashPermission = await Agent.get(ctx.agent).then((x) => x.permission.bash!) + const permissions = typeof bashPermission === "string" + ? { "*": bashPermission } + : bashPermission const askPatterns = new Set() for (const node of tree.rootNode.descendantsOfType("command")) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 1d6372090e..9224722880 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -18,6 +18,7 @@ import path from "path" import { type ToolDefinition } from "@opencode-ai/plugin" import z from "zod/v4" import { Plugin } from "../plugin" +import { Wildcard } from "../util/wildcard" export namespace ToolRegistry { export const state = Instance.state(async () => { @@ -120,12 +121,35 @@ export namespace ToolRegistry { result["patch"] = false result["write"] = false } - if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) { + if (typeof agent.permission.bash === "object" && + agent.permission.bash["*"] === "deny" && + Object.keys(agent.permission.bash).length === 1) { + result["bash"] = false + } else if (agent.permission.bash === "deny") { result["bash"] = false } if (agent.permission.webfetch === "deny") { result["webfetch"] = false } + + // Check tool permissions for all tools (including MCP tools) + // Extract tool permissions from the flattened permission object + const toolPermissions = Object.fromEntries( + Object.entries(agent.permission).filter( + ([key]) => !["edit", "bash", "webfetch"].includes(key) + ) + ) + + // Get all available MCP tools + const mcpTools = await import("../mcp").then(m => m.MCP.tools()).catch(() => ({})) + + // Check each tool's permission using wildcard matching + for (const toolName of Object.keys(mcpTools)) { + const permission = Wildcard.all(toolName, toolPermissions) + if (permission === "deny") { + result[toolName] = false + } + } return result } diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 169f7bb4a6..dad61f8410 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -234,6 +234,7 @@ export type AgentConfig = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + [key: string]: "ask" | "allow" | "deny" | undefined | (("ask" | "allow" | "deny") | {[key: string]: "ask" | "allow" | "deny"}) } [key: string]: | unknown @@ -252,6 +253,7 @@ export type AgentConfig = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + [key: string]: "ask" | "allow" | "deny" | undefined | (("ask" | "allow" | "deny") | {[key: string]: "ask" | "allow" | "deny"}) } | undefined } @@ -484,6 +486,7 @@ export type Config = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + [key: string]: "ask" | "allow" | "deny" | undefined | (("ask" | "allow" | "deny") | {[key: string]: "ask" | "allow" | "deny"}) } tools?: { [key: string]: boolean @@ -1013,6 +1016,7 @@ export type Agent = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + [key: string]: "ask" | "allow" | "deny" | undefined | {[key: string]: "ask" | "allow" | "deny"} } model?: { modelID: string diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 4579c21277..097ec1362a 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -145,6 +145,61 @@ Use the `permission.webfetch` key to control whether the LLM can fetch web pages --- +## Tool Permissions + +In addition to the core tools (`edit`, `bash`, `webfetch`), you can control permissions for any tool using wildcard patterns directly in the permission object. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow", + "bash": "ask", + "webfetch": "deny", + "read": "allow", + "write": "ask", + "mcp_*": "ask", + "context7_*": "deny", + "my_custom_tool": "allow" + } +} +``` + +This allows fine-grained control over: +- **Built-in tools** — `read`, `write`, `grep`, `glob`, `list`, `patch`, `todowrite`, etc. +- **MCP tools** — Any tool from MCP servers using patterns like `mcp_*` or `server_name_*` +- **Custom tools** — Tools you've created in your configuration + +--- + +### Wildcards for Tools + +You can use wildcard patterns to control groups of tools efficiently. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow", + "bash": "ask", + "webfetch": "deny", + "*": "deny", + "read": "allow", + "grep": "allow", + "mcp_*": "ask", + "developer_*": "allow" + } +} +``` + +In this example: +- All tools are denied by default (`"*": "deny"`) +- Specific tools like `read` and `grep` are allowed +- All MCP tools require approval (`mcp_*`) +- Tools from a specific developer server are allowed (`developer_*`) + +--- + ## Agents You can also configure permissions per agent. Where the agent specific config