Skip to content
22 changes: 18 additions & 4 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ 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.record(z.string(), z.union([
Config.Permission,
z.record(z.string(), Config.Permission)
])).refine((data) => {
// Ensure required fields exist and have correct types
return 'edit' in data &&
'bash' in data &&
(typeof data.bash === 'string' || typeof data.bash === 'object');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't need to do this, you should be able to leave it as it was but add a catchall for the undefined permissions

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example:

agent: z
.object({
plan: Agent.optional(),
build: Agent.optional(),
general: Agent.optional(),
})
.catchall(Agent)

And after cleaning this up it should allow you to not have to have the type assertions in some of the other places

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll follow up on this over the weekend. Typescript isn't really a language I'm strong in, but I'll make the changes for this.

Copy link
Author

@SamInTheShell SamInTheShell Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had Claude make the updates, they passed the typecheck and commit hooks. I wont be able to actually test till the weekend. Figured I'd just get the changes in so it's at least available for now.

My suspicion is that it's change for the bash permissions fallback was erroneous as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rekram1-node I did testing last night and this morning. The changes work. I only changed the part where it had a fallback permission for bash, I think defaults should already be handled in agents.ts

Let me know if anything else needs some work.

}),
model: z
.object({
Expand All @@ -45,6 +49,7 @@ export namespace Agent {
"*": "allow",
},
webfetch: "allow",
"*": "ask", // Default for all tools
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})

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

function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
// Handle bash permission normalization
if (typeof basePermission.bash === "string") {
basePermission.bash = {
"*": basePermission.bash,
Expand All @@ -186,7 +193,10 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
"*": overridePermission.bash,
}
}

const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any

// Handle bash merging
let mergedBash
if (merged.bash) {
if (typeof merged.bash === "string") {
Expand All @@ -207,6 +217,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