Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ export namespace Config {
// Convert legacy maxSteps to steps
const steps = agent.steps ?? agent.maxSteps

return { ...agent, options, permission, steps } as typeof agent & {
const { tools, maxSteps, ...rest } = agent
return { ...rest, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: Permission
steps?: number
Expand Down
68 changes: 47 additions & 21 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "../session/truncation"
import { PermissionNext } from "../permission/next"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
Expand Down Expand Up @@ -115,25 +116,50 @@ export namespace ToolRegistry {
return all().then((x) => x.map((t) => t.id))
}

export async function tools(providerID: string, agent?: Agent.Info) {
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
return true
})
.map(async (t) => {
using _ = log.time(t.id)
return {
id: t.id,
...(await t.init({ agent })),
}
}),
)
return result
}
export async function tools(providerID: string, agent?: Agent.Info) {
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}

// Filter based on agent permissions if provided
if (agent?.permission && Array.isArray(agent.permission) && agent.permission.length > 0) {
// Get all rules for this specific tool
// Note: pattern field is intentionally ignored here - patterns are checked
// at command execution time, not tool availability time
const toolRules = agent.permission.filter(
(rule: PermissionNext.Rule) => rule.permission === t.id
)

if (toolRules.length > 0) {
// Tool has explicit rules - only filter out if ALL rules are "deny"
const allDeny = toolRules.every((rule: PermissionNext.Rule) => rule.action === "deny")
return !allDeny
}

// No specific rules for this tool - check for catch-all deny
const hasCatchAllDeny = agent.permission.some(
(rule: PermissionNext.Rule) => rule.permission === "*" && rule.action === "deny"
)
if (hasCatchAllDeny) {
return false
}
}

return true
})
.map(async (t) => {
using _ = log.time(t.id)
return {
id: t.id,
...(await t.init({ agent })),
}
}),
)
return result
}
}
230 changes: 230 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { ToolRegistry } from "../../src/tool/registry"
import { Instance } from "../../src/project/instance"

const projectRoot = path.join(__dirname, "../..")

describe("ToolRegistry.tools() permission filtering", () => {
test("filters out tool with all-deny rules", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [
{ permission: "bash", pattern: "*", action: "deny" as const }
],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)
const toolIds = result.map(t => t.id)

expect(toolIds).not.toContain("bash")
},
})
})

test("includes tool with mixed allow/deny rules", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [
{ permission: "bash", pattern: "git*", action: "allow" as const },
{ permission: "bash", pattern: "*", action: "deny" as const }
],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)
const toolIds = result.map(t => t.id)

expect(toolIds).toContain("bash")
},
})
})

test("catch-all deny filters tools without explicit rules", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [
{ permission: "bash", pattern: "*", action: "allow" as const },
{ permission: "*", pattern: "*", action: "deny" as const }
],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)
const toolIds = result.map(t => t.id)

expect(toolIds).toContain("bash")
expect(toolIds).not.toContain("edit")
},
})
})

test("includes all tools when permission is empty array", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)

expect(result.length).toBeGreaterThan(0)
},
})
})

test("includes all tools when agent has no permission field", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// When no agent is passed, all tools should be included
const result = await ToolRegistry.tools("anthropic", undefined)

expect(result.length).toBeGreaterThan(0)
},
})
})

test("returns bash tool when no permissions set", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await ToolRegistry.tools("anthropic")

const bashTool = result.find(t => t.id === "bash")
expect(bashTool).toBeDefined()
},
})
})

test("returns edit tool when no permissions set", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await ToolRegistry.tools("anthropic")

const editTool = result.find(t => t.id === "edit")
expect(editTool).toBeDefined()
},
})
})

test("tool has proper structure with description and parameters", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await ToolRegistry.tools("anthropic")

const bashTool = result.find(t => t.id === "bash")
expect(bashTool).toBeDefined()
expect(bashTool?.description).toBeDefined()
expect(typeof bashTool?.description).toBe("string")
expect(bashTool?.parameters).toBeDefined()
},
})
})

test("handles allow rule for specific tool", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [
{ permission: "bash", pattern: "*", action: "allow" as const }
],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)
const toolIds = result.map(t => t.id)

expect(toolIds).toContain("bash")
},
})
})

test("ask action is treated as allow", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [
{ permission: "bash", pattern: "*", action: "ask" as const }
],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)
const toolIds = result.map(t => t.id)

expect(toolIds).toContain("bash")
},
})
})

test("empty permission array bypasses filtering", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const allToolsResult = await ToolRegistry.tools("anthropic")

const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [],
options: {}
}

const filteredResult = await ToolRegistry.tools("anthropic", agent as any)

expect(filteredResult.length).toBe(allToolsResult.length)
},
})
})

test("multiple specific allow rules work correctly", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const agent = {
name: "test-agent",
mode: "subagent" as const,
permission: [
{ permission: "bash", pattern: "*", action: "allow" as const },
{ permission: "read", pattern: "*", action: "allow" as const },
{ permission: "edit", pattern: "*", action: "deny" as const }
],
options: {}
}

const result = await ToolRegistry.tools("anthropic", agent as any)
const toolIds = result.map(t => t.id)

expect(toolIds).toContain("bash")
expect(toolIds).toContain("read")
expect(toolIds).not.toContain("edit")
},
})
})
})