Skip to content
29 changes: 22 additions & 7 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -45,6 +47,7 @@ export namespace Agent {
"*": "allow",
},
webfetch: "allow",
"*": "ask", // Default for all tools
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})

Expand All @@ -53,6 +56,7 @@ export namespace Agent {
edit: "deny",
bash: "ask",
webfetch: "allow",
"*": "ask", // Default for all tools
},
cfg.permission ?? {},
)
Expand Down Expand Up @@ -175,7 +179,11 @@ export namespace Agent {
}
}

function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
function mergeAgentPermissions(
basePermission: Partial<Agent.Info["permission"]>,
overridePermission: Partial<Agent.Info["permission"]>,
): Agent.Info["permission"] {
// Handle bash permission normalization
if (typeof basePermission.bash === "string") {
basePermission.bash = {
"*": basePermission.bash,
Expand All @@ -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") {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
Expand Down
26 changes: 25 additions & 1 deletion packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/web/src/content/docs/permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down