summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-01-01 17:54:11 -0500
committerGitHub <[email protected]>2026-01-01 17:54:11 -0500
commit351ddeed914d237138fc6f3f8b3d65d2e559357a (patch)
treea3009b5b42e80b50095835152e334596f843f3cb
parentdccb8875ad0e114242fce3dabc7f7e31c1bac29b (diff)
downloadopencode-351ddeed914d237138fc6f3f8b3d65d2e559357a.tar.gz
opencode-351ddeed914d237138fc6f3f8b3d65d2e559357a.zip
Permission rework (#6319)
Co-authored-by: Github Action <[email protected]> Co-authored-by: Adam <[email protected]>
-rw-r--r--.github/workflows/test.yml6
-rw-r--r--.opencode/agent/git-committer.md10
-rw-r--r--.opencode/opencode.jsonc8
-rw-r--r--bunfig.toml4
-rw-r--r--flake.lock6
-rw-r--r--package.json3
-rw-r--r--packages/app/src/context/global-sync.tsx10
-rw-r--r--packages/app/src/context/permission.tsx14
-rw-r--r--packages/app/src/pages/layout.tsx2
-rw-r--r--packages/opencode/src/acp/agent.ts31
-rw-r--r--packages/opencode/src/agent/agent.ts322
-rw-r--r--packages/opencode/src/cli/cmd/agent.ts3
-rw-r--r--packages/opencode/src/cli/cmd/debug/agent.ts25
-rw-r--r--packages/opencode/src/cli/cmd/run.ts6
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx1
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/local.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx42
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx941
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx313
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog.tsx1
-rw-r--r--packages/opencode/src/config/config.ts131
-rw-r--r--packages/opencode/src/installation/index.ts1
-rw-r--r--packages/opencode/src/permission/arity.ts163
-rw-r--r--packages/opencode/src/permission/index.ts6
-rw-r--r--packages/opencode/src/permission/next.ts253
-rw-r--r--packages/opencode/src/plugin/index.ts1
-rw-r--r--packages/opencode/src/server/server.ts52
-rw-r--r--packages/opencode/src/session/index.ts13
-rw-r--r--packages/opencode/src/session/llm.ts14
-rw-r--r--packages/opencode/src/session/processor.ts43
-rw-r--r--packages/opencode/src/session/prompt.ts213
-rw-r--r--packages/opencode/src/session/system.ts2
-rw-r--r--packages/opencode/src/tool/bash.ts100
-rw-r--r--packages/opencode/src/tool/codesearch.ts24
-rw-r--r--packages/opencode/src/tool/edit.ts114
-rw-r--r--packages/opencode/src/tool/glob.ts12
-rw-r--r--packages/opencode/src/tool/grep.ts13
-rw-r--r--packages/opencode/src/tool/ls.ts11
-rw-r--r--packages/opencode/src/tool/lsp.ts9
-rw-r--r--packages/opencode/src/tool/patch.ts57
-rw-r--r--packages/opencode/src/tool/read.ts44
-rw-r--r--packages/opencode/src/tool/registry.ts24
-rw-r--r--packages/opencode/src/tool/skill.ts140
-rw-r--r--packages/opencode/src/tool/task.ts35
-rw-r--r--packages/opencode/src/tool/todo.ts22
-rw-r--r--packages/opencode/src/tool/tool.ts2
-rw-r--r--packages/opencode/src/tool/webfetch.ts26
-rw-r--r--packages/opencode/src/tool/websearch.ts30
-rw-r--r--packages/opencode/src/tool/write.ts58
-rw-r--r--packages/opencode/test/agent/agent.test.ts468
-rw-r--r--packages/opencode/test/config/config.test.ts220
-rw-r--r--packages/opencode/test/fixture/fixture.ts11
-rw-r--r--packages/opencode/test/permission/arity.test.ts33
-rw-r--r--packages/opencode/test/permission/next.test.ts652
-rw-r--r--packages/opencode/test/tool/bash.test.ts420
-rw-r--r--packages/opencode/test/tool/grep.test.ts1
-rw-r--r--packages/opencode/test/tool/patch.test.ts8
-rw-r--r--packages/opencode/test/tool/read.test.ts105
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts44
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts278
-rw-r--r--packages/sdk/openapi.json4
-rw-r--r--packages/ui/src/components/message-part.tsx19
-rw-r--r--packages/ui/src/components/session-turn.tsx24
-rw-r--r--packages/ui/src/context/data.tsx4
66 files changed, 3587 insertions, 2075 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ac1a24fd5..c39710bee 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,11 +2,9 @@ name: test
on:
push:
- branches-ignore:
- - production
+ branches:
+ - dev
pull_request:
- branches-ignore:
- - production
workflow_dispatch:
jobs:
test:
diff --git a/.opencode/agent/git-committer.md b/.opencode/agent/git-committer.md
deleted file mode 100644
index 49c3e3de1..000000000
--- a/.opencode/agent/git-committer.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-description: Use this agent when you are asked to commit and push code changes to a git repository.
-mode: subagent
----
-
-You commit and push to git
-
-Commit messages should be brief since they are used to generate release notes.
-
-Messages should say WHY the change was made and not WHAT was changed.
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index cbcbb0c65..f547e874d 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -10,7 +10,13 @@
"options": {},
},
},
- "mcp": {},
+ "permission": "ask",
+ "mcp": {
+ "context7": {
+ "type": "remote",
+ "url": "https://mcp.context7.com/mcp",
+ },
+ },
"tools": {
"github-triage": false,
},
diff --git a/bunfig.toml b/bunfig.toml
index b6874be14..36a21d933 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,2 +1,6 @@
[install]
exact = true
+
+[test]
+root = "./do-not-run-tests-from-root"
+
diff --git a/flake.lock b/flake.lock
index 2a06923c2..ad18c3c63 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1767151656,
- "narHash": "sha256-ujL2AoYBnJBN262HD95yer7QYUmYp5kFZGYbyCCKxq8=",
+ "lastModified": 1767242400,
+ "narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "f665af0cdb70ed27e1bd8f9fdfecaf451260fc55",
+ "rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a",
"type": "github"
},
"original": {
diff --git a/package.json b/package.json
index aa7031bec..577ca4650 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
- "hello": "echo 'Hello World!'"
+ "hello": "echo 'Hello World!'",
+ "test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index dd040d8d5..92de0a636 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -15,7 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
- type Permission,
+ type PermissionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -46,7 +46,7 @@ type State = {
[sessionID: string]: Todo[]
}
permission: {
- [sessionID: string]: Permission[]
+ [sessionID: string]: PermissionRequest[]
}
mcp: {
[name: string]: McpStatus
@@ -168,7 +168,7 @@ function createGlobalSync() {
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
permission: () =>
sdk.permission.list().then((x) => {
- const grouped: Record<string, Permission[]> = {}
+ const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
@@ -349,7 +349,7 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch })
break
}
- case "permission.updated": {
+ case "permission.asked": {
const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID]
if (!permissions) {
@@ -375,7 +375,7 @@ function createGlobalSync() {
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) break
- const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
+ const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
if (!result.found) break
setStore(
"permission",
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index a0ad1ee05..061470361 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -1,7 +1,7 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { Permission } from "@opencode-ai/sdk/v2/client"
+import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
@@ -14,10 +14,8 @@ type PermissionRespondFn = (input: {
directory?: string
}) => void
-const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
-
-function shouldAutoAccept(perm: Permission) {
- return AUTO_ACCEPT_TYPES.has(perm.type)
+function shouldAutoAccept(perm: PermissionRequest) {
+ return perm.permission === "edit"
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
@@ -48,7 +46,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
- function respondOnce(permission: Permission, directory?: string) {
+ function respondOnce(permission: PermissionRequest, directory?: string) {
if (responded.has(permission.id)) return
responded.add(permission.id)
respond({
@@ -65,7 +63,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
- if (event?.type !== "permission.updated") return
+ if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return
@@ -98,7 +96,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return {
ready,
respond,
- autoResponds(permission: Permission) {
+ autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
},
isAutoAccepting,
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 0bcf0f7a2..7aa1e2448 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -175,7 +175,7 @@ export default function Layout(props: ParentProps) {
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
- if (e.details?.type !== "permission.updated") return
+ if (e.details?.type !== "permission.asked") return
const directory = e.name
const perm = e.details.properties
if (permission.autoResponds(perm)) return
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index e6419dd76..bab4d2b82 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -71,19 +71,19 @@ export namespace ACP {
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
- case "permission.updated":
+ case "permission.asked":
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId,
toolCall: {
- toolCallId: permission.callID ?? permission.id,
+ toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
- title: permission.title,
+ title: permission.permission,
rawInput: permission.metadata,
- kind: toToolKind(permission.type),
- locations: toLocations(permission.type, permission.metadata),
+ kind: toToolKind(permission.permission),
+ locations: toLocations(permission.permission, permission.metadata),
},
options,
})
@@ -93,28 +93,25 @@ export namespace ACP {
permissionID: permission.id,
sessionID: permission.sessionID,
})
- await this.config.sdk.permission.respond({
- sessionID: permission.sessionID,
- permissionID: permission.id,
- response: "reject",
+ await this.config.sdk.permission.reply({
+ requestID: permission.id,
+ reply: "reject",
directory,
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
- await this.config.sdk.permission.respond({
- sessionID: permission.sessionID,
- permissionID: permission.id,
- response: "reject",
+ await this.config.sdk.permission.reply({
+ requestID: permission.id,
+ reply: "reject",
directory,
})
return
}
- await this.config.sdk.permission.respond({
- sessionID: permission.sessionID,
- permissionID: permission.id,
- response: res.outcome.optionId as "once" | "always" | "reject",
+ await this.config.sdk.permission.reply({
+ requestID: permission.id,
+ reply: res.outcome.optionId as "once" | "always" | "reject",
directory,
})
} catch (err) {
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index ad665e5d6..db49b0f4f 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -4,16 +4,14 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
-import { mergeDeep } from "remeda"
-import { Log } from "../util/log"
-
-const log = Log.create({ service: "agent" })
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
+import { PermissionNext } from "@/permission/next"
+import { mergeDeep, pipe, sortBy, values } from "remeda"
export namespace Agent {
export const Info = z
@@ -23,18 +21,10 @@ export namespace Agent {
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
- default: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
- permission: z.object({
- edit: Config.Permission,
- bash: z.record(z.string(), Config.Permission),
- skill: z.record(z.string(), Config.Permission),
- webfetch: Config.Permission.optional(),
- doom_loop: Config.Permission.optional(),
- external_directory: Config.Permission.optional(),
- }),
+ permission: PermissionNext.Ruleset,
model: z
.object({
modelID: z.string(),
@@ -42,9 +32,8 @@ export namespace Agent {
})
.optional(),
prompt: z.string().optional(),
- tools: z.record(z.string(), z.boolean()),
options: z.record(z.string(), z.any()),
- maxSteps: z.number().int().positive().optional(),
+ steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
@@ -53,113 +42,74 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()
- const defaultTools = cfg.tools ?? {}
- const defaultPermission: Info["permission"] = {
- edit: "allow",
- bash: {
- "*": "allow",
- },
- skill: {
- "*": "allow",
- },
- webfetch: "allow",
+
+ const defaults = PermissionNext.fromConfig({
+ "*": "allow",
doom_loop: "ask",
external_directory: "ask",
- }
- const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
-
- const planPermission = mergeAgentPermissions(
- {
- edit: "deny",
- bash: {
- "cut*": "allow",
- "diff*": "allow",
- "du*": "allow",
- "file *": "allow",
- "find * -delete*": "ask",
- "find * -exec*": "ask",
- "find * -fprint*": "ask",
- "find * -fls*": "ask",
- "find * -fprintf*": "ask",
- "find * -ok*": "ask",
- "find *": "allow",
- "git diff*": "allow",
- "git log*": "allow",
- "git show*": "allow",
- "git status*": "allow",
- "git branch": "allow",
- "git branch -v": "allow",
- "grep*": "allow",
- "head*": "allow",
- "less*": "allow",
- "ls*": "allow",
- "more*": "allow",
- "pwd*": "allow",
- "rg*": "allow",
- "sort --output=*": "ask",
- "sort -o *": "ask",
- "sort*": "allow",
- "stat*": "allow",
- "tail*": "allow",
- "tree -o *": "ask",
- "tree*": "allow",
- "uniq*": "allow",
- "wc*": "allow",
- "whereis*": "allow",
- "which*": "allow",
- "*": "ask",
- },
- webfetch: "allow",
- },
- cfg.permission ?? {},
- )
+ })
+ const user = PermissionNext.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = {
build: {
name: "build",
- tools: { ...defaultTools },
options: {},
- permission: agentPermission,
+ permission: PermissionNext.merge(defaults, user),
mode: "primary",
native: true,
},
plan: {
name: "plan",
options: {},
- permission: planPermission,
- tools: {
- ...defaultTools,
- },
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ edit: {
+ "*": "deny",
+ ".opencode/plan/*.md": "allow",
+ },
+ }),
+ user,
+ ),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
- tools: {
- todoread: false,
- todowrite: false,
- ...defaultTools,
- },
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ todoread: "deny",
+ todowrite: "deny",
+ }),
+ user,
+ ),
options: {},
- permission: agentPermission,
mode: "subagent",
native: true,
hidden: true,
},
explore: {
name: "explore",
- tools: {
- todoread: false,
- todowrite: false,
- edit: false,
- write: false,
- ...defaultTools,
- },
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ "*": "deny",
+ grep: "allow",
+ glob: "allow",
+ list: "allow",
+ bash: "allow",
+ webfetch: "allow",
+ websearch: "allow",
+ codesearch: "allow",
+ read: "allow",
+ }),
+ user,
+ ),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
- permission: agentPermission,
mode: "subagent",
native: true,
},
@@ -169,11 +119,14 @@ export namespace Agent {
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
- tools: {
- "*": false,
- },
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ "*": "deny",
+ }),
+ user,
+ ),
options: {},
- permission: agentPermission,
},
title: {
name: "title",
@@ -181,9 +134,14 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
- permission: agentPermission,
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ "*": "deny",
+ }),
+ user,
+ ),
prompt: PROMPT_TITLE,
- tools: {},
},
summary: {
name: "summary",
@@ -191,11 +149,17 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
- permission: agentPermission,
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ "*": "deny",
+ }),
+ user,
+ ),
prompt: PROMPT_SUMMARY,
- tools: {},
},
}
+
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
@@ -206,74 +170,22 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
- permission: agentPermission,
+ permission: PermissionNext.merge(defaults, user),
options: {},
- tools: {},
native: false,
}
- const {
- name,
- model,
- prompt,
- tools,
- description,
- temperature,
- top_p,
- mode,
- permission,
- color,
- maxSteps,
- ...extra
- } = value
- item.options = {
- ...item.options,
- ...extra,
- }
- if (model) item.model = Provider.parseModel(model)
- if (prompt) item.prompt = prompt
- if (tools)
- item.tools = {
- ...item.tools,
- ...tools,
- }
- item.tools = {
- ...defaultTools,
- ...item.tools,
- }
- if (description) item.description = description
- if (temperature != undefined) item.temperature = temperature
- if (top_p != undefined) item.topP = top_p
- if (mode) item.mode = mode
- if (color) item.color = color
- // just here for consistency & to prevent it from being added as an option
- if (name) item.name = name
- if (maxSteps != undefined) item.maxSteps = maxSteps
-
- if (permission ?? cfg.permission) {
- item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
- }
+ if (value.model) item.model = Provider.parseModel(value.model)
+ item.prompt = value.prompt ?? item.prompt
+ item.description = value.description ?? item.description
+ item.temperature = value.temperature ?? item.temperature
+ item.topP = value.top_p ?? item.topP
+ item.mode = value.mode ?? item.mode
+ item.color = value.color ?? item.color
+ item.name = value.options?.name ?? item.name
+ item.steps = value.steps ?? item.steps
+ item.options = mergeDeep(item.options, value.options ?? {})
+ item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
-
- // Mark the default agent
- const defaultName = cfg.default_agent ?? "build"
- const defaultCandidate = result[defaultName]
- if (defaultCandidate && defaultCandidate.mode !== "subagent") {
- defaultCandidate.default = true
- } else {
- // Fall back to "build" if configured default is invalid
- if (result["build"]) {
- result["build"].default = true
- }
- }
-
- const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
- if (!hasPrimaryAgents) {
- throw new Config.InvalidError({
- path: "config",
- message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
- })
- }
-
return result
})
@@ -282,13 +194,16 @@ export namespace Agent {
}
export async function list() {
- return state().then((x) => Object.values(x))
+ const cfg = await Config.get()
+ return pipe(
+ await state(),
+ values(),
+ sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
+ )
}
- export async function defaultAgent(): Promise<string> {
- const agents = await state()
- const defaultCandidate = Object.values(agents).find((a) => a.default)
- return defaultCandidate?.name ?? "build"
+ export async function defaultAgent() {
+ return state().then((x) => Object.keys(x)[0])
}
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
@@ -329,70 +244,3 @@ export namespace Agent {
return result.object
}
}
-
-function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
- if (typeof basePermission.bash === "string") {
- basePermission.bash = {
- "*": basePermission.bash,
- }
- }
- if (typeof overridePermission.bash === "string") {
- overridePermission.bash = {
- "*": overridePermission.bash,
- }
- }
-
- if (typeof basePermission.skill === "string") {
- basePermission.skill = {
- "*": basePermission.skill,
- }
- }
- if (typeof overridePermission.skill === "string") {
- overridePermission.skill = {
- "*": overridePermission.skill,
- }
- }
- const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
- let mergedBash
- if (merged.bash) {
- if (typeof merged.bash === "string") {
- mergedBash = {
- "*": merged.bash,
- }
- } else if (typeof merged.bash === "object") {
- mergedBash = mergeDeep(
- {
- "*": "allow",
- },
- merged.bash,
- )
- }
- }
-
- let mergedSkill
- if (merged.skill) {
- if (typeof merged.skill === "string") {
- mergedSkill = {
- "*": merged.skill,
- }
- } else if (typeof merged.skill === "object") {
- mergedSkill = mergeDeep(
- {
- "*": "allow",
- },
- merged.skill,
- )
- }
- }
-
- const result: Agent.Info["permission"] = {
- edit: merged.edit ?? "allow",
- webfetch: merged.webfetch ?? "allow",
- bash: mergedBash ?? { "*": "allow" },
- skill: mergedSkill ?? { "*": "allow" },
- doom_loop: merged.doom_loop,
- external_directory: merged.external_directory,
- }
-
- return result
-}
diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts
index 60dd9cc75..b57de0ae4 100644
--- a/packages/opencode/src/cli/cmd/agent.ts
+++ b/packages/opencode/src/cli/cmd/agent.ts
@@ -241,7 +241,8 @@ const AgentListCommand = cmd({
})
for (const agent of sortedAgents) {
- process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
+ process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
+ process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
}
},
})
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
index 5a51a044d..6bd04a0ee 100644
--- a/packages/opencode/src/cli/cmd/debug/agent.ts
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -1,9 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
-import { Provider } from "../../../provider/provider"
-import { ToolRegistry } from "../../../tool/registry"
-import { Wildcard } from "../../../util/wildcard"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -25,27 +22,7 @@ export const AgentCommand = cmd({
)
process.exit(1)
}
- const resolvedTools = await resolveTools(agent)
- const output = {
- ...agent,
- tools: resolvedTools,
- toolOverrides: agent.tools,
- }
- process.stdout.write(JSON.stringify(output, null, 2) + EOL)
+ process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
})
},
})
-
-async function resolveTools(agent: Agent.Info) {
- const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
- const toolOverrides = {
- ...agent.tools,
- ...(await ToolRegistry.enabled(agent)),
- }
- const availableTools = await ToolRegistry.tools(providerID, agent)
- const resolved: Record<string, boolean> = {}
- for (const tool of availableTools) {
- resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
- }
- return resolved
-}
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 0c371b864..876b64bd8 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
break
}
- if (event.type === "permission.updated") {
+ if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
- message: `Permission required to run: ${permission.title}`,
+ message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
options: [
{ value: "once", label: "Allow once" },
- { value: "always", label: "Always allow" },
+ { value: "always", label: "Always allow: " + permission.always.join(", ") },
{ value: "reject", label: "Reject" },
],
initialValue: "once",
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 8b7b68273..35b33b4a0 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation"
-import { Global } from "@/global"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index ab9487e1d..ed0f50b2c 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -33,6 +33,7 @@ import { useKV } from "../../context/kv"
export type PromptProps = {
sessionID?: string
+ visible?: boolean
disabled?: boolean
onSubmit?: () => void
ref?: (ref: PromptRef) => void
@@ -373,7 +374,8 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
- input.focus()
+ if (props.visible !== false) input?.focus()
+ if (props.visible === false) input?.blur()
})
onMount(() => {
@@ -798,7 +800,7 @@ export function Prompt(props: PromptProps) {
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
- <box ref={(r) => (anchor = r)}>
+ <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index ec7d86eaa..b60a775b3 100644
--- a/packages/opencode/src/cli/cmd/tui/context/local.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
- current: agents().find((x) => x.default)?.name ?? agents()[0].name,
+ current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 2528a4998..cc4e9c69a 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -7,7 +7,7 @@ import type {
Config,
Todo,
Command,
- Permission,
+ PermissionRequest,
LspStatus,
McpStatus,
FormatterStatus,
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: Agent[]
command: Command[]
permission: {
- [sessionID: string]: Permission[]
+ [sessionID: string]: PermissionRequest[]
}
config: Config
session: Session[]
@@ -97,36 +97,38 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
- case "permission.updated": {
- const permissions = store.permission[event.properties.sessionID]
- if (!permissions) {
- setStore("permission", event.properties.sessionID, [event.properties])
- break
- }
- const match = Binary.search(permissions, event.properties.id, (p) => p.id)
+ case "permission.replied": {
+ const requests = store.permission[event.properties.sessionID]
+ if (!requests) break
+ const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
+ if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
- if (match.found) {
- draft[match.index] = event.properties
- return
- }
- draft.push(event.properties)
+ draft.splice(match.index, 1)
}),
)
break
}
- case "permission.replied": {
- const permissions = store.permission[event.properties.sessionID]
- const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
- if (!match.found) break
+ case "permission.asked": {
+ const request = event.properties
+ const requests = store.permission[request.sessionID]
+ if (!requests) {
+ setStore("permission", request.sessionID, [request])
+ break
+ }
+ const match = Binary.search(requests, request.id, (r) => r.id)
+ if (match.found) {
+ setStore("permission", request.sessionID, match.index, reconcile(request))
+ break
+ }
setStore(
"permission",
- event.properties.sessionID,
+ request.sessionID,
produce((draft) => {
- draft.splice(match.index, 1)
+ draft.splice(match.index, 0, request)
}),
)
break
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
index 69082c870..3d1315ccd 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
@@ -59,7 +59,7 @@ export function Footer() {
<Match when={connected()}>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
- <span style={{ fg: theme.warning }}>◉</span> {permissions().length} Permission
+ <span style={{ fg: theme.warning }}>△</span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 374645abb..8a6c5cdd2 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -9,7 +9,6 @@ import {
Show,
Switch,
useContext,
- type Component,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
@@ -23,6 +22,7 @@ import {
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
+ TextAttributes,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
@@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
-import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
+import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "@tui/context/keybind"
@@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
+import { PermissionPrompt } from "./permission"
import { DialogExportOptions } from "../../ui/dialog-export-options"
addDefaultParsers(parsers.parsers)
@@ -82,12 +83,12 @@ class CustomSpeedScroll implements ScrollAcceleration {
const context = createContext<{
width: number
+ sessionID: string
conceal: () => boolean
showThinking: () => boolean
showTimestamps: () => boolean
usernameVisible: () => boolean
showDetails: () => boolean
- userMessageMarkdown: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
}>()
@@ -106,8 +107,17 @@ export function Session() {
const { theme } = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID)!)
+ const children = createMemo(() => {
+ const parentID = session()?.parentID ?? session()?.id
+ return sync.data.session
+ .filter((x) => x.parentID === parentID || x.id === parentID)
+ .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ })
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
- const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
+ const permissions = createMemo(() => {
+ if (session().parentID) return sync.data.permission[route.sessionID] ?? []
+ return children().flatMap((x) => sync.data.permission[x.id] ?? [])
+ })
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -125,7 +135,6 @@ export function Session() {
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
- const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
@@ -176,28 +185,6 @@ export function Session() {
}
})
- // Auto-navigate to whichever session currently needs permission input
- createEffect(() => {
- const currentSession = session()
- if (!currentSession) return
- const currentPermissions = permissions()
- let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
-
- if (!targetID) {
- const child = sync.data.session.find(
- (x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
- )
- if (child) targetID = child.id
- }
-
- if (targetID && targetID !== currentSession.id) {
- navigate({
- type: "session",
- sessionID: targetID,
- })
- }
- })
-
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
@@ -248,29 +235,6 @@ export function Session() {
dialog.clear()
}
- useKeyboard((evt) => {
- if (dialog.stack.length > 0) return
-
- const first = permissions()[0]
- if (first) {
- const response = iife(() => {
- if (evt.ctrl || evt.meta) return
- if (evt.name === "return") return "once"
- if (evt.name === "a") return "always"
- if (evt.name === "d") return "reject"
- if (evt.name === "escape") return "reject"
- return
- })
- if (response) {
- sdk.client.permission.respond({
- permissionID: first.id,
- sessionID: route.sessionID,
- response: response,
- })
- }
- }
- })
-
function toBottom() {
setTimeout(() => {
if (scroll) scroll.scrollTo(scroll.scrollHeight)
@@ -280,18 +244,14 @@ export function Session() {
const local = useLocal()
function moveChild(direction: number) {
- const parentID = session()?.parentID ?? session()?.id
- let children = sync.data.session
- .filter((x) => x.parentID === parentID || x.id === parentID)
- .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- if (children.length === 1) return
- let next = children.findIndex((x) => x.id === session()?.id) + direction
- if (next >= children.length) next = 0
- if (next < 0) next = children.length - 1
- if (children[next]) {
+ if (children().length === 1) return
+ let next = children().findIndex((x) => x.id === session()?.id) + direction
+ if (next >= children().length) next = 0
+ if (next < 0) next = children().length - 1
+ if (children()[next]) {
navigate({
type: "session",
- sessionID: children[next].id,
+ sessionID: children()[next].id,
})
}
}
@@ -572,19 +532,6 @@ export function Session() {
},
},
{
- title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown",
- value: "session.toggle.user_message_markdown",
- category: "Session",
- onSelect: (dialog) => {
- setUserMessageMarkdown((prev) => {
- const next = !prev
- kv.set("user_message_markdown", next)
- return next
- })
- dialog.clear()
- },
- },
- {
title: animationsEnabled() ? "Disable animations" : "Enable animations",
value: "session.toggle.animations",
category: "Session",
@@ -990,12 +937,12 @@ export function Session() {
get width() {
return contentWidth()
},
+ sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
usernameVisible,
showDetails,
- userMessageMarkdown,
diffWrapMode,
sync,
}}
@@ -1121,7 +1068,11 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
+ <Show when={permissions().length > 0}>
+ <PermissionPrompt request={permissions()[0]} />
+ </Show>
<Prompt
+ visible={!session().parentID && permissions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1169,7 +1120,7 @@ function UserMessage(props: {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
- const { theme, syntax } = useTheme()
+ const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
@@ -1200,22 +1151,7 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
- <Switch>
- <Match when={ctx.userMessageMarkdown()}>
- <code
- filetype="markdown"
- drawUnstyledText={false}
- streaming={false}
- syntaxStyle={syntax()}
- content={text()?.text ?? ""}
- conceal={ctx.conceal()}
- fg={theme.text}
- />
- </Match>
- <Match when={!ctx.userMessageMarkdown()}>
- <text fg={theme.text}>{text()?.text}</text>
- </Match>
- </Switch>
+ <text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -1321,7 +1257,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Match when={props.last || final()}>
<box paddingLeft={3}>
<text marginTop={1}>
- <span style={{ fg: local.agent.color(props.message.mode) }}>▣ </span>{" "}
+ <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
<Show when={duration()}>
@@ -1397,112 +1333,77 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
// Pending messages moved to individual tool pending functions
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
- const { theme } = useTheme()
- const { showDetails } = use()
const sync = useSync()
- const [margin, setMargin] = createSignal(0)
- const component = createMemo(() => {
- // Hide tool if showDetails is false and tool completed successfully
- // But always show if there's an error or permission is required
- const shouldHide =
- !showDetails() &&
- props.part.state.status === "completed" &&
- !sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID)
-
- if (shouldHide) {
- return undefined
- }
- const render = ToolRegistry.render(props.part.tool) ?? GenericTool
-
- const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
- const input = props.part.state.input ?? {}
- const container = ToolRegistry.container(props.part.tool)
- const permissions = sync.data.permission[props.message.sessionID] ?? []
- const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
- const permission = permissions[permissionIndex]
-
- const style: BoxProps =
- container === "block" || permission
- ? {
- border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
- paddingTop: 1,
- paddingBottom: 1,
- paddingLeft: 2,
- marginTop: 1,
- gap: 1,
- backgroundColor: theme.backgroundPanel,
- customBorderChars: SplitBorder.customBorderChars,
- borderColor: permissionIndex === 0 ? theme.warning : theme.background,
- }
- : {
- paddingLeft: 3,
- }
-
- return (
- <box
- marginTop={margin()}
- {...style}
- renderBefore={function () {
- const el = this as BoxRenderable
- const parent = el.parent
- if (!parent) {
- return
- }
- if (el.height > 1) {
- setMargin(1)
- return
- }
- const children = parent.getChildren()
- const index = children.indexOf(el)
- const previous = children[index - 1]
- if (!previous) {
- setMargin(0)
- return
- }
- if (previous.height > 1 || previous.id.startsWith("text-")) {
- setMargin(1)
- return
- }
- }}
- >
- <Dynamic
- component={render}
- input={input}
- tool={props.part.tool}
- metadata={metadata}
- permission={permission?.metadata ?? {}}
- output={props.part.state.status === "completed" ? props.part.state.output : undefined}
- />
- {props.part.state.status === "error" && (
- <box paddingLeft={2}>
- <text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
- </box>
- )}
- {permission && (
- <box gap={1}>
- <text fg={theme.text}>Permission required to run this tool:</text>
- <box flexDirection="row" gap={2}>
- <text fg={theme.text}>
- <b>enter</b>
- <span style={{ fg: theme.textMuted }}> accept</span>
- </text>
- <text fg={theme.text}>
- <b>a</b>
- <span style={{ fg: theme.textMuted }}> accept always</span>
- </text>
- <text fg={theme.text}>
- <b>d</b>
- <span style={{ fg: theme.textMuted }}> deny</span>
- </text>
- </box>
- </box>
- )}
- </box>
- )
- })
+ const toolprops = {
+ get metadata() {
+ return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
+ },
+ get input() {
+ return props.part.state.input ?? {}
+ },
+ get output() {
+ return props.part.state.status === "completed" ? props.part.state.output : undefined
+ },
+ get permission() {
+ const permissions = sync.data.permission[props.message.sessionID] ?? []
+ const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID)
+ return permissions[permissionIndex]
+ },
+ get tool() {
+ return props.part.tool
+ },
+ get part() {
+ return props.part
+ },
+ }
- return <Show when={component()}>{component()}</Show>
+ return (
+ <Switch>
+ <Match when={props.part.tool === "bash"}>
+ <Bash {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "glob"}>
+ <Glob {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "read"}>
+ <Read {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "grep"}>
+ <Grep {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "list"}>
+ <List {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "webfetch"}>
+ <WebFetch {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "codesearch"}>
+ <CodeSearch {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "websearch"}>
+ <WebSearch {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "write"}>
+ <Write {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "edit"}>
+ <Edit {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "task"}>
+ <Task {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "patch"}>
+ <Patch {...toolprops} />
+ </Match>
+ <Match when={props.part.tool === "todowrite"}>
+ <TodoWrite {...toolprops} />
+ </Match>
+ <Match when={true}>
+ <GenericTool {...toolprops} />
+ </Match>
+ </Switch>
+ )
}
type ToolProps<T extends Tool.Info> = {
@@ -1511,37 +1412,16 @@ type ToolProps<T extends Tool.Info> = {
permission: Record<string, any>
tool: string
output?: string
+ part: ToolPart
}
function GenericTool(props: ToolProps<any>) {
return (
- <ToolTitle icon="⚙" fallback="Writing command..." when={true}>
+ <InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
{props.tool} {input(props.input)}
- </ToolTitle>
+ </InlineTool>
)
}
-type ToolRegistration<T extends Tool.Info = any> = {
- name: string
- container: "inline" | "block"
- render?: Component<ToolProps<T>>
-}
-const ToolRegistry = (() => {
- const state: Record<string, ToolRegistration> = {}
- function register<T extends Tool.Info>(input: ToolRegistration<T>) {
- state[input.name] = input
- return input
- }
- return {
- register,
- container(name: string) {
- return state[name]?.container
- },
- render(name: string) {
- return state[name]?.render
- },
- }
-})()
-
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
const { theme } = useTheme()
return (
@@ -1553,67 +1433,135 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
)
}
-ToolRegistry.register<typeof BashTool>({
- name: "bash",
- container: "block",
- render(props) {
- const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
- const { theme } = useTheme()
- return (
- <>
- <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
- {props.input.description || "Shell"}
- </ToolTitle>
- <Show when={props.input.command}>
- <text fg={theme.text}>$ {props.input.command}</text>
+function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) {
+ const [margin, setMargin] = createSignal(0)
+ const { theme } = useTheme()
+ const ctx = use()
+ const sync = useSync()
+
+ const permission = createMemo(() => {
+ const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
+ if (!callID) return false
+ return callID === props.part.callID
+ })
+
+ const fg = createMemo(() => {
+ if (permission()) return theme.warning
+ if (props.complete) return theme.textMuted
+ return theme.text
+ })
+
+ const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
+
+ const denied = createMemo(() => error()?.includes("rejected permission"))
+
+ return (
+ <box
+ marginTop={margin()}
+ paddingLeft={3}
+ renderBefore={function () {
+ const el = this as BoxRenderable
+ const parent = el.parent
+ if (!parent) {
+ return
+ }
+ if (el.height > 1) {
+ setMargin(1)
+ return
+ }
+ const children = parent.getChildren()
+ const index = children.indexOf(el)
+ const previous = children[index - 1]
+ if (!previous) {
+ setMargin(0)
+ return
+ }
+ if (previous.height > 1 || previous.id.startsWith("text-")) {
+ setMargin(1)
+ return
+ }
+ }}
+ >
+ <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
+ <Show fallback={<>~ {props.pending}</>} when={props.complete}>
+ <span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
- <Show when={output()}>
- <box>
+ </text>
+ <Show when={error() && !denied()}>
+ <text fg={theme.error}>{error()}</text>
+ </Show>
+ </box>
+ )
+}
+
+function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) {
+ const { theme } = useTheme()
+ const renderer = useRenderer()
+ const [hover, setHover] = createSignal(false)
+ return (
+ <box
+ border={["left"]}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ marginTop={1}
+ gap={1}
+ backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.background}
+ onMouseOver={() => props.onClick && setHover(true)}
+ onMouseOut={() => setHover(false)}
+ onMouseUp={() => {
+ if (renderer.getSelection()?.getSelectedText()) return
+ props.onClick?.()
+ }}
+ >
+ <text paddingLeft={3} fg={theme.textMuted}>
+ {props.title}
+ </text>
+ {props.children}
+ </box>
+ )
+}
+
+function Bash(props: ToolProps<typeof BashTool>) {
+ const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
+ const { theme } = useTheme()
+ return (
+ <Switch>
+ <Match when={props.metadata.output !== undefined}>
+ <BlockTool title={"# " + (props.input.description ?? "Shell")}>
+ <box gap={1}>
+ <text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>{output()}</text>
</box>
- </Show>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof ReadTool>({
- name: "read",
- container: "inline",
- render(props) {
- return (
- <>
- <ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
- Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
- </ToolTitle>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof WriteTool>({
- name: "write",
- container: "block",
- render(props) {
- const { theme, syntax } = useTheme()
- const code = createMemo(() => {
- if (!props.input.content) return ""
- return props.input.content
- })
-
- const diagnostics = createMemo(() => {
- const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
- return props.metadata.diagnostics?.[filePath] ?? []
- })
-
- const done = !!props.input.filePath
-
- return (
- <>
- <ToolTitle icon="←" fallback="Preparing write..." when={done}>
- Wrote {props.input.filePath}
- </ToolTitle>
- <Show when={done}>
+ </BlockTool>
+ </Match>
+ <Match when={true}>
+ <InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
+ {props.input.command}
+ </InlineTool>
+ </Match>
+ </Switch>
+ )
+}
+
+function Write(props: ToolProps<typeof WriteTool>) {
+ const { theme, syntax } = useTheme()
+ const code = createMemo(() => {
+ if (!props.input.content) return ""
+ return props.input.content
+ })
+
+ const diagnostics = createMemo(() => {
+ const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+ return props.metadata.diagnostics?.[filePath] ?? []
+ })
+
+ return (
+ <Switch>
+ <Match when={props.metadata.diagnostics !== undefined}>
+ <BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -1623,180 +1571,168 @@ ToolRegistry.register<typeof WriteTool>({
content={code()}
/>
</line_number>
- </Show>
- <Show when={diagnostics().length}>
- <For each={diagnostics()}>
- {(diagnostic) => (
- <text fg={theme.error}>
- Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
- </text>
- )}
- </For>
- </Show>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof GlobTool>({
- name: "glob",
- container: "inline",
- render(props) {
- return (
- <>
- <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
- Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
- <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
- </ToolTitle>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof GrepTool>({
- name: "grep",
- container: "inline",
- render(props) {
- return (
- <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
- Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
- <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
- </ToolTitle>
- )
- },
-})
-
-ToolRegistry.register<typeof ListTool>({
- name: "list",
- container: "inline",
- render(props) {
- const dir = createMemo(() => {
- if (props.input.path) {
- return normalizePath(props.input.path)
- }
- return ""
- })
- return (
- <>
- <ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
- List {dir()}
- </ToolTitle>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof TaskTool>({
- name: "task",
- container: "block",
- render(props) {
- const { theme } = useTheme()
- const keybind = useKeybind()
- const dialog = useDialog()
- const renderer = useRenderer()
-
- return (
- <>
- <ToolTitle icon="◉" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
- {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
- </ToolTitle>
- <Show when={props.metadata.summary?.length}>
- <box>
- <For each={props.metadata.summary ?? []}>
- {(task, index) => {
- const summary = props.metadata.summary ?? []
- return (
- <text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
- {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
- {task.state.status === "completed" ? task.state.title : ""}
- </text>
- )
- }}
+ <Show when={diagnostics().length}>
+ <For each={diagnostics()}>
+ {(diagnostic) => (
+ <text fg={theme.error}>
+ Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
+ </text>
+ )}
</For>
+ </Show>
+ </BlockTool>
+ </Match>
+ <Match when={true}>
+ <InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
+ Write {normalizePath(props.input.filePath!)}
+ </InlineTool>
+ </Match>
+ </Switch>
+ )
+}
+
+function Glob(props: ToolProps<typeof GlobTool>) {
+ return (
+ <InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
+ Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
+ <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
+ </InlineTool>
+ )
+}
+
+function Read(props: ToolProps<typeof ReadTool>) {
+ return (
+ <InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
+ Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
+ </InlineTool>
+ )
+}
+
+function Grep(props: ToolProps<typeof GrepTool>) {
+ return (
+ <InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
+ Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
+ <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
+ </InlineTool>
+ )
+}
+
+function List(props: ToolProps<typeof ListTool>) {
+ const dir = createMemo(() => {
+ if (props.input.path) {
+ return normalizePath(props.input.path)
+ }
+ return ""
+ })
+ return (
+ <InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
+ List {dir()}
+ </InlineTool>
+ )
+}
+
+function WebFetch(props: ToolProps<typeof WebFetchTool>) {
+ return (
+ <InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
+ WebFetch {(props.input as any).url}
+ </InlineTool>
+ )
+}
+
+function CodeSearch(props: ToolProps<any>) {
+ const input = props.input as any
+ const metadata = props.metadata as any
+ return (
+ <InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
+ Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
+ </InlineTool>
+ )
+}
+
+function WebSearch(props: ToolProps<any>) {
+ const input = props.input as any
+ const metadata = props.metadata as any
+ return (
+ <InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
+ Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
+ </InlineTool>
+ )
+}
+
+function Task(props: ToolProps<typeof TaskTool>) {
+ const { theme } = useTheme()
+ const keybind = useKeybind()
+ const { navigate } = useRoute()
+
+ const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
+
+ return (
+ <Switch>
+ <Match when={props.metadata.summary?.length}>
+ <BlockTool
+ title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
+ onClick={
+ props.metadata.sessionId
+ ? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
+ : undefined
+ }
+ >
+ <box>
+ <text style={{ fg: theme.textMuted }}>
+ {props.input.description} ({props.metadata.summary?.length} toolcalls)
+ </text>
+ <Show when={current()}>
+ <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
+ └ {Locale.titlecase(current()!.tool)}{" "}
+ {current()!.state.status === "completed" ? current()!.state.title : ""}
+ </text>
+ </Show>
</box>
- </Show>
- <text fg={theme.text}>
- {keybind.print("session_child_cycle")}
- <span style={{ fg: theme.textMuted }}> view subagents</span>
- </text>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof WebFetchTool>({
- name: "webfetch",
- container: "inline",
- render(props) {
- return (
- <ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
- WebFetch {(props.input as any).url}
- </ToolTitle>
- )
- },
-})
-
-ToolRegistry.register({
- name: "codesearch",
- container: "inline",
- render(props: ToolProps<any>) {
- const input = props.input as any
- const metadata = props.metadata as any
- return (
- <ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
- Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
- </ToolTitle>
- )
- },
-})
-
-ToolRegistry.register({
- name: "websearch",
- container: "inline",
- render(props: ToolProps<any>) {
- const input = props.input as any
- const metadata = props.metadata as any
- return (
- <ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
- Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
- </ToolTitle>
- )
- },
-})
-
-ToolRegistry.register<typeof EditTool>({
- name: "edit",
- container: "block",
- render(props) {
- const ctx = use()
- const { theme, syntax } = useTheme()
-
- const view = createMemo(() => {
- const diffStyle = ctx.sync.data.config.tui?.diff_style
- if (diffStyle === "stacked") return "unified"
- // Default to "auto" behavior
- return ctx.width > 120 ? "split" : "unified"
- })
-
- const ft = createMemo(() => filetype(props.input.filePath))
-
- const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
-
- const diagnostics = createMemo(() => {
- const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
- const arr = props.metadata.diagnostics?.[filePath] ?? []
- return arr.filter((x) => x.severity === 1).slice(0, 3)
- })
-
- return (
- <>
- <ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
- Edit {normalizePath(props.input.filePath!)}{" "}
- {input({
- replaceAll: props.input.replaceAll,
- })}
- </ToolTitle>
- <Show when={diffContent()}>
+ <text fg={theme.text}>
+ {keybind.print("session_child_cycle")}
+ <span style={{ fg: theme.textMuted }}> view subagents</span>
+ </text>
+ </BlockTool>
+ </Match>
+ <Match when={true}>
+ <InlineTool
+ icon="◉"
+ pending="Delegating..."
+ complete={props.input.subagent_type ?? props.input.description}
+ part={props.part}
+ >
+ {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
+ </InlineTool>
+ </Match>
+ </Switch>
+ )
+}
+
+function Edit(props: ToolProps<typeof EditTool>) {
+ const ctx = use()
+ const { theme, syntax } = useTheme()
+
+ const view = createMemo(() => {
+ const diffStyle = ctx.sync.data.config.tui?.diff_style
+ if (diffStyle === "stacked") return "unified"
+ // Default to "auto" behavior
+ return ctx.width > 120 ? "split" : "unified"
+ })
+
+ const ft = createMemo(() => filetype(props.input.filePath))
+
+ const diffContent = createMemo(() => props.metadata.diff)
+
+ const diagnostics = createMemo(() => {
+ const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+ const arr = props.metadata.diagnostics?.[filePath] ?? []
+ return arr.filter((x) => x.severity === 1).slice(0, 3)
+ })
+
+ return (
+ <Switch>
+ <Match when={props.metadata.diff !== undefined}>
+ <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -1818,66 +1754,69 @@ ToolRegistry.register<typeof EditTool>({
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
- </Show>
- <Show when={diagnostics().length}>
- <box>
- <For each={diagnostics()}>
- {(diagnostic) => (
- <text fg={theme.error}>
- Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
- </text>
- )}
- </For>
- </box>
- </Show>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof PatchTool>({
- name: "patch",
- container: "block",
- render(props) {
- const { theme } = useTheme()
- return (
- <>
- <ToolTitle icon="%" fallback="Preparing patch..." when={true}>
- Patch
- </ToolTitle>
- <Show when={props.output}>
+ <Show when={diagnostics().length}>
+ <box>
+ <For each={diagnostics()}>
+ {(diagnostic) => (
+ <text fg={theme.error}>
+ Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
+ {diagnostic.message}
+ </text>
+ )}
+ </For>
+ </box>
+ </Show>
+ </BlockTool>
+ </Match>
+ <Match when={true}>
+ <InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
+ Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
+ </InlineTool>
+ </Match>
+ </Switch>
+ )
+}
+
+function Patch(props: ToolProps<typeof PatchTool>) {
+ const { theme } = useTheme()
+ return (
+ <Switch>
+ <Match when={props.output !== undefined}>
+ <BlockTool title="# Patch">
<box>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
- </Show>
- </>
- )
- },
-})
-
-ToolRegistry.register<typeof TodoWriteTool>({
- name: "todowrite",
- container: "block",
- render(props) {
- const { theme } = useTheme()
- return (
- <>
- <Show when={!props.input.todos?.length}>
- <ToolTitle icon="⚙" fallback="Updating todos..." when={true}>
- Updating todos...
- </ToolTitle>
- </Show>
- <Show when={props.metadata.todos?.length}>
+ </BlockTool>
+ </Match>
+ <Match when={true}>
+ <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
+ Patch
+ </InlineTool>
+ </Match>
+ </Switch>
+ )
+}
+
+function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
+ return (
+ <Switch>
+ <Match when={props.metadata.todos?.length}>
+ <BlockTool title="# Todos">
<box>
<For each={props.input.todos ?? []}>
{(todo) => <TodoItem status={todo.status} content={todo.content} />}
</For>
</box>
- </Show>
- </>
- )
- },
-})
+ </BlockTool>
+ </Match>
+ <Match when={true}>
+ <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
+ Updating todos...
+ </InlineTool>
+ </Match>
+ </Switch>
+ )
+}
function normalizePath(input?: string) {
if (!input) return ""
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
new file mode 100644
index 000000000..e3d519115
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -0,0 +1,313 @@
+import { createStore } from "solid-js/store"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
+import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
+import { useTheme } from "../../context/theme"
+import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../../context/sdk"
+import { SplitBorder } from "../../component/border"
+import { useSync } from "../../context/sync"
+import path from "path"
+import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import { Locale } from "@/util/locale"
+
+function normalizePath(input?: string) {
+ if (!input) return ""
+ if (path.isAbsolute(input)) {
+ return path.relative(process.cwd(), input) || "."
+ }
+ return input
+}
+
+function filetype(input?: string) {
+ if (!input) return "none"
+ const ext = path.extname(input)
+ const language = LANGUAGE_EXTENSIONS[ext]
+ if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
+ return language
+}
+
+function EditBody(props: { request: PermissionRequest }) {
+ const { theme, syntax } = useTheme()
+ const sync = useSync()
+ const dimensions = useTerminalDimensions()
+
+ const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
+ const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
+
+ const view = createMemo(() => {
+ const diffStyle = sync.data.config.tui?.diff_style
+ if (diffStyle === "stacked") return "unified"
+ return dimensions().width > 120 ? "split" : "unified"
+ })
+
+ const ft = createMemo(() => filetype(filepath()))
+
+ return (
+ <box flexDirection="column" gap={1}>
+ <box flexDirection="row" gap={1} paddingLeft={1}>
+ <text fg={theme.textMuted}>{"→"}</text>
+ <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
+ </box>
+ <Show when={diff()}>
+ <box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
+ <diff
+ diff={diff()}
+ view={view()}
+ filetype={ft()}
+ syntaxStyle={syntax()}
+ showLineNumbers={true}
+ width="100%"
+ wrapMode="word"
+ fg={theme.text}
+ addedBg={theme.diffAddedBg}
+ removedBg={theme.diffRemovedBg}
+ contextBg={theme.diffContextBg}
+ addedSignColor={theme.diffHighlightAdded}
+ removedSignColor={theme.diffHighlightRemoved}
+ lineNumberFg={theme.diffLineNumber}
+ lineNumberBg={theme.diffContextBg}
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
+ />
+ </box>
+ </Show>
+ </box>
+ )
+}
+
+function TextBody(props: { title: string; description?: string; icon?: string }) {
+ const { theme } = useTheme()
+ return (
+ <>
+ <box flexDirection="row" gap={1} paddingLeft={1}>
+ <Show when={props.icon}>
+ <text fg={theme.textMuted} flexShrink={0}>
+ {props.icon}
+ </text>
+ </Show>
+ <text fg={theme.textMuted}>{props.title}</text>
+ </box>
+ <Show when={props.description}>
+ <box paddingLeft={1}>
+ <text fg={theme.text}>{props.description}</text>
+ </box>
+ </Show>
+ </>
+ )
+}
+
+export function PermissionPrompt(props: { request: PermissionRequest }) {
+ const sdk = useSDK()
+ const sync = useSync()
+ const [store, setStore] = createStore({
+ always: false,
+ })
+
+ const input = createMemo(() => {
+ const tool = props.request.tool
+ if (!tool) return {}
+ const parts = sync.data.part[tool.messageID] ?? []
+ for (const part of parts) {
+ if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
+ return part.state.input ?? {}
+ }
+ }
+ return {}
+ })
+
+ const { theme } = useTheme()
+
+ return (
+ <Switch>
+ <Match when={store.always}>
+ <Prompt
+ title="Always allow"
+ body={
+ <Switch>
+ <Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
+ <TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
+ </Match>
+ <Match when={true}>
+ <box paddingLeft={1} gap={1}>
+ <text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
+ <box>
+ <For each={props.request.always}>
+ {(pattern) => (
+ <text fg={theme.text}>
+ {"- "}
+ {pattern}
+ </text>
+ )}
+ </For>
+ </box>
+ </box>
+ </Match>
+ </Switch>
+ }
+ options={{ confirm: "Confirm", cancel: "Cancel" }}
+ onSelect={(option) => {
+ setStore("always", false)
+ if (option === "cancel") return
+ sdk.client.permission.reply({
+ reply: "always",
+ requestID: props.request.id,
+ })
+ }}
+ />
+ </Match>
+ <Match when={!store.always}>
+ <Prompt
+ title="Permission required"
+ body={
+ <Switch>
+ <Match when={props.request.permission === "edit"}>
+ <EditBody request={props.request} />
+ </Match>
+ <Match when={props.request.permission === "read"}>
+ <TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
+ </Match>
+ <Match when={props.request.permission === "glob"}>
+ <TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
+ </Match>
+ <Match when={props.request.permission === "grep"}>
+ <TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
+ </Match>
+ <Match when={props.request.permission === "list"}>
+ <TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
+ </Match>
+ <Match when={props.request.permission === "bash"}>
+ <TextBody
+ icon="#"
+ title={(input().description as string) ?? ""}
+ description={("$ " + input().command) as string}
+ />
+ </Match>
+ <Match when={props.request.permission === "task"}>
+ <TextBody
+ icon="#"
+ title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
+ description={"◉ " + input().description}
+ />
+ </Match>
+ <Match when={props.request.permission === "webfetch"}>
+ <TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
+ </Match>
+ <Match when={props.request.permission === "websearch"}>
+ <TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
+ </Match>
+ <Match when={props.request.permission === "codesearch"}>
+ <TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
+ </Match>
+ <Match when={props.request.permission === "external_directory"}>
+ <TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
+ </Match>
+ <Match when={props.request.permission === "doom_loop"}>
+ <TextBody icon="⟳" title="Continue after repeated failures" />
+ </Match>
+ <Match when={true}>
+ <TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
+ </Match>
+ </Switch>
+ }
+ options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
+ onSelect={(option) => {
+ if (option === "always") {
+ setStore("always", true)
+ return
+ }
+ sdk.client.permission.reply({
+ reply: option as "once" | "reject",
+ requestID: props.request.id,
+ })
+ }}
+ />
+ </Match>
+ </Switch>
+ )
+}
+
+function Prompt<const T extends Record<string, string>>(props: {
+ title: string
+ body: JSX.Element
+ options: T
+ onSelect: (option: keyof T) => void
+}) {
+ const { theme } = useTheme()
+ const keys = Object.keys(props.options) as (keyof T)[]
+ const [store, setStore] = createStore({
+ selected: keys[0],
+ })
+
+ useKeyboard((evt) => {
+ if (evt.name === "left" || evt.name == "h") {
+ evt.preventDefault()
+ const idx = keys.indexOf(store.selected)
+ const next = keys[(idx - 1 + keys.length) % keys.length]
+ setStore("selected", next)
+ }
+
+ if (evt.name === "right" || evt.name == "l") {
+ evt.preventDefault()
+ const idx = keys.indexOf(store.selected)
+ const next = keys[(idx + 1) % keys.length]
+ setStore("selected", next)
+ }
+
+ if (evt.name === "return") {
+ evt.preventDefault()
+ props.onSelect(store.selected)
+ }
+ })
+
+ return (
+ <box
+ backgroundColor={theme.backgroundPanel}
+ border={["left"]}
+ borderColor={theme.warning}
+ customBorderChars={SplitBorder.customBorderChars}
+ >
+ <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
+ <box flexDirection="row" gap={1} paddingLeft={1}>
+ <text fg={theme.warning}>{"△"}</text>
+ <text fg={theme.text}>{props.title}</text>
+ </box>
+ {props.body}
+ </box>
+ <box
+ flexDirection="row"
+ flexShrink={0}
+ gap={1}
+ paddingTop={1}
+ paddingLeft={2}
+ paddingRight={3}
+ paddingBottom={1}
+ backgroundColor={theme.backgroundElement}
+ justifyContent="space-between"
+ >
+ <box flexDirection="row" gap={1}>
+ <For each={keys}>
+ {(option) => (
+ <box
+ paddingLeft={1}
+ paddingRight={1}
+ backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
+ >
+ <text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
+ {props.options[option]}
+ </text>
+ </box>
+ )}
+ </For>
+ </box>
+ <box flexDirection="row" gap={2}>
+ <text fg={theme.text}>
+ {"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
+ </text>
+ <text fg={theme.text}>
+ enter <span style={{ fg: theme.textMuted }}>confirm</span>
+ </text>
+ </box>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
index 9b773111c..79bca4240 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
@@ -99,6 +99,7 @@ function init() {
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
+ focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index f66b46790..5d95814d7 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -123,13 +123,22 @@ export namespace Config {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
- if (!result.username) result.username = os.userInfo().username
-
- // Handle migration from autoshare to share field
- if (result.autoshare === true && !result.share) {
- result.share = "auto"
+ // Backwards compatibility: legacy top-level `tools` config
+ if (result.tools) {
+ const perms: Record<string, Config.PermissionAction> = {}
+ for (const [tool, enabled] of Object.entries(result.tools)) {
+ const action: Config.PermissionAction = enabled ? "allow" : "deny"
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ perms.edit = action
+ continue
+ }
+ perms[tool] = action
+ }
+ result.permission = mergeDeep(perms, result.permission ?? {})
}
+ if (!result.username) result.username = os.userInfo().username
+
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
@@ -368,7 +377,45 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
- export const Permission = z.enum(["ask", "allow", "deny"])
+ export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
+ ref: "PermissionActionConfig",
+ })
+ export type PermissionAction = z.infer<typeof PermissionAction>
+
+ export const PermissionObject = z.record(z.string(), PermissionAction).meta({
+ ref: "PermissionObjectConfig",
+ })
+ export type PermissionObject = z.infer<typeof PermissionObject>
+
+ export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
+ ref: "PermissionRuleConfig",
+ })
+ export type PermissionRule = z.infer<typeof PermissionRule>
+
+ export const Permission = z
+ .object({
+ read: PermissionRule.optional(),
+ edit: PermissionRule.optional(),
+ glob: PermissionRule.optional(),
+ grep: PermissionRule.optional(),
+ list: PermissionRule.optional(),
+ bash: PermissionRule.optional(),
+ task: PermissionRule.optional(),
+ external_directory: PermissionRule.optional(),
+ todowrite: PermissionAction.optional(),
+ todoread: PermissionAction.optional(),
+ webfetch: PermissionAction.optional(),
+ websearch: PermissionAction.optional(),
+ codesearch: PermissionAction.optional(),
+ lsp: PermissionRule.optional(),
+ doom_loop: PermissionAction.optional(),
+ })
+ .catchall(PermissionRule)
+ .or(PermissionAction)
+ .transform((x) => (typeof x === "string" ? { "*": x } : x))
+ .meta({
+ ref: "PermissionConfig",
+ })
export type Permission = z.infer<typeof Permission>
export const Command = z.object({
@@ -386,33 +433,70 @@ export namespace Config {
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
- tools: z.record(z.string(), z.boolean()).optional(),
+ tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
+ options: z.record(z.string(), z.any()).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.optional()
.describe("Hex color code for the agent (e.g., #FF5733)"),
- maxSteps: z
+ steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
- permission: z
- .object({
- edit: Permission.optional(),
- bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
- skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
- webfetch: Permission.optional(),
- doom_loop: Permission.optional(),
- external_directory: Permission.optional(),
- })
- .optional(),
+ maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
+ permission: Permission.optional(),
})
.catchall(z.any())
+ .transform((agent, ctx) => {
+ const knownKeys = new Set([
+ "model",
+ "prompt",
+ "description",
+ "temperature",
+ "top_p",
+ "mode",
+ "color",
+ "steps",
+ "maxSteps",
+ "options",
+ "permission",
+ "disable",
+ "tools",
+ ])
+
+ // Extract unknown properties into options
+ const options: Record<string, unknown> = { ...agent.options }
+ for (const [key, value] of Object.entries(agent)) {
+ if (!knownKeys.has(key)) options[key] = value
+ }
+
+ // Convert legacy tools config to permissions
+ const permission: Permission = { ...agent.permission }
+ for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
+ const action = enabled ? "allow" : "deny"
+ // write, edit, patch, multiedit all map to edit permission
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ permission.edit = action
+ } else {
+ permission[tool] = action
+ }
+ }
+
+ // Convert legacy maxSteps to steps
+ const steps = agent.steps ?? agent.maxSteps
+
+ return { ...agent, options, permission, steps } as typeof agent & {
+ options?: Record<string, unknown>
+ permission?: Permission
+ steps?: number
+ }
+ })
.meta({
ref: "AgentConfig",
})
@@ -785,16 +869,7 @@ export namespace Config {
),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
- permission: z
- .object({
- edit: Permission.optional(),
- bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
- skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
- webfetch: Permission.optional(),
- doom_loop: Permission.optional(),
- external_directory: Permission.optional(),
- })
- .optional(),
+ permission: Permission.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts
index 975ca749b..25ef79fda 100644
--- a/packages/opencode/src/installation/index.ts
+++ b/packages/opencode/src/installation/index.ts
@@ -158,6 +158,7 @@ export namespace Installation {
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
+ await $`${process.execPath} --version`.nothrow().quiet().text()
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts
new file mode 100644
index 000000000..948841c8e
--- /dev/null
+++ b/packages/opencode/src/permission/arity.ts
@@ -0,0 +1,163 @@
+export namespace BashArity {
+ export function prefix(tokens: string[]) {
+ for (let len = tokens.length; len > 0; len--) {
+ const prefix = tokens.slice(0, len).join(" ")
+ const arity = ARITY[prefix]
+ if (arity !== undefined) return tokens.slice(0, arity)
+ }
+ if (tokens.length === 0) return []
+ return tokens.slice(0, 1)
+ }
+
+ /* Generated with following prompt:
+You are generating a dictionary of command-prefix arities for bash-style commands.
+This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
+2. **Flags NEVER count as tokens**. Only subcommands count.
+3. **Longest matching prefix wins**.
+4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
+5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
+6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed)
+* `git checkout main` → `git checkout` (because `git` has arity 2)
+* `npm install` → `npm install` (because `npm` has arity 2)
+* `npm run dev` → `npm run dev` (because `npm run` has arity 3)
+* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
+*/
+ const ARITY: Record<string, number> = {
+ cat: 1, // cat file.txt
+ cd: 1, // cd /path/to/dir
+ chmod: 1, // chmod 755 script.sh
+ chown: 1, // chown user:group file.txt
+ cp: 1, // cp source.txt dest.txt
+ echo: 1, // echo "hello world"
+ env: 1, // env
+ export: 1, // export PATH=/usr/bin
+ grep: 1, // grep pattern file.txt
+ kill: 1, // kill 1234
+ killall: 1, // killall process
+ ln: 1, // ln -s source target
+ ls: 1, // ls -la
+ mkdir: 1, // mkdir new-dir
+ mv: 1, // mv old.txt new.txt
+ ps: 1, // ps aux
+ pwd: 1, // pwd
+ rm: 1, // rm file.txt
+ rmdir: 1, // rmdir empty-dir
+ sleep: 1, // sleep 5
+ source: 1, // source ~/.bashrc
+ tail: 1, // tail -f log.txt
+ touch: 1, // touch file.txt
+ unset: 1, // unset VAR
+ which: 1, // which node
+ aws: 3, // aws s3 ls
+ az: 3, // az storage blob list
+ bazel: 2, // bazel build
+ brew: 2, // brew install node
+ bun: 2, // bun install
+ "bun run": 3, // bun run dev
+ "bun x": 3, // bun x vite
+ cargo: 2, // cargo build
+ "cargo add": 3, // cargo add tokio
+ "cargo run": 3, // cargo run main
+ cdk: 2, // cdk deploy
+ cf: 2, // cf push app
+ cmake: 2, // cmake build
+ composer: 2, // composer require laravel
+ consul: 2, // consul members
+ "consul kv": 3, // consul kv get config/app
+ crictl: 2, // crictl ps
+ deno: 2, // deno run server.ts
+ "deno task": 3, // deno task dev
+ doctl: 3, // doctl kubernetes cluster list
+ docker: 2, // docker run nginx
+ "docker builder": 3, // docker builder prune
+ "docker compose": 3, // docker compose up
+ "docker container": 3, // docker container ls
+ "docker image": 3, // docker image prune
+ "docker network": 3, // docker network inspect
+ "docker volume": 3, // docker volume ls
+ eksctl: 2, // eksctl get clusters
+ "eksctl create": 3, // eksctl create cluster
+ firebase: 2, // firebase deploy
+ flyctl: 2, // flyctl deploy
+ gcloud: 3, // gcloud compute instances list
+ gh: 3, // gh pr list
+ git: 2, // git checkout main
+ "git config": 3, // git config user.name
+ "git remote": 3, // git remote add origin
+ "git stash": 3, // git stash pop
+ go: 2, // go build
+ gradle: 2, // gradle build
+ helm: 2, // helm install mychart
+ heroku: 2, // heroku logs
+ hugo: 2, // hugo new site blog
+ ip: 2, // ip link show
+ "ip addr": 3, // ip addr show
+ "ip link": 3, // ip link set eth0 up
+ "ip netns": 3, // ip netns exec foo bash
+ "ip route": 3, // ip route add default via 1.1.1.1
+ kind: 2, // kind delete cluster
+ "kind create": 3, // kind create cluster
+ kubectl: 2, // kubectl get pods
+ "kubectl kustomize": 3, // kubectl kustomize overlays/dev
+ "kubectl rollout": 3, // kubectl rollout restart deploy/api
+ kustomize: 2, // kustomize build .
+ make: 2, // make build
+ mc: 2, // mc ls myminio
+ "mc admin": 3, // mc admin info myminio
+ minikube: 2, // minikube start
+ mongosh: 2, // mongosh test
+ mysql: 2, // mysql -u root
+ mvn: 2, // mvn compile
+ ng: 2, // ng generate component home
+ npm: 2, // npm install
+ "npm exec": 3, // npm exec vite
+ "npm init": 3, // npm init vue
+ "npm run": 3, // npm run dev
+ "npm view": 3, // npm view react version
+ nvm: 2, // nvm use 18
+ nx: 2, // nx build
+ openssl: 2, // openssl genrsa 2048
+ "openssl req": 3, // openssl req -new -key key.pem
+ "openssl x509": 3, // openssl x509 -in cert.pem
+ pip: 2, // pip install numpy
+ pipenv: 2, // pipenv install flask
+ pnpm: 2, // pnpm install
+ "pnpm dlx": 3, // pnpm dlx create-next-app
+ "pnpm exec": 3, // pnpm exec vite
+ "pnpm run": 3, // pnpm run dev
+ poetry: 2, // poetry add requests
+ podman: 2, // podman run alpine
+ "podman container": 3, // podman container ls
+ "podman image": 3, // podman image prune
+ psql: 2, // psql -d mydb
+ pulumi: 2, // pulumi up
+ "pulumi stack": 3, // pulumi stack output
+ pyenv: 2, // pyenv install 3.11
+ python: 2, // python -m venv env
+ rake: 2, // rake db:migrate
+ rbenv: 2, // rbenv install 3.2.0
+ "redis-cli": 2, // redis-cli ping
+ rustup: 2, // rustup update
+ serverless: 2, // serverless invoke
+ sfdx: 3, // sfdx force:org:list
+ skaffold: 2, // skaffold dev
+ sls: 2, // sls deploy
+ sst: 2, // sst deploy
+ swift: 2, // swift build
+ systemctl: 2, // systemctl restart nginx
+ terraform: 2, // terraform apply
+ "terraform workspace": 3, // terraform workspace select prod
+ tmux: 2, // tmux new -s dev
+ turbo: 2, // turbo run build
+ ufw: 2, // ufw allow 22
+ vault: 2, // vault login
+ "vault auth": 3, // vault auth list
+ "vault kv": 3, // vault kv get secret/api
+ vercel: 2, // vercel deploy
+ volta: 2, // volta install node
+ wp: 2, // wp plugin install
+ yarn: 2, // yarn add react
+ "yarn dlx": 3, // yarn dlx create-react-app
+ "yarn run": 3, // yarn run dev
+ }
+}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index cbfeb6a9b..f1cd43fdb 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -27,7 +27,7 @@ export namespace Permission {
sessionID: z.string(),
messageID: z.string(),
callID: z.string().optional(),
- title: z.string(),
+ message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
@@ -99,7 +99,7 @@ export namespace Permission {
export async function ask(input: {
type: Info["type"]
- title: Info["title"]
+ message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
@@ -123,7 +123,7 @@ export namespace Permission {
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
- title: input.title,
+ message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts
new file mode 100644
index 000000000..4f7d831e7
--- /dev/null
+++ b/packages/opencode/src/permission/next.ts
@@ -0,0 +1,253 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Config } from "@/config/config"
+import { Identifier } from "@/id/id"
+import { Instance } from "@/project/instance"
+import { Storage } from "@/storage/storage"
+import { fn } from "@/util/fn"
+import { Log } from "@/util/log"
+import { Wildcard } from "@/util/wildcard"
+import z from "zod"
+
+export namespace PermissionNext {
+ const log = Log.create({ service: "permission" })
+
+ export const Action = z.enum(["allow", "deny", "ask"]).meta({
+ ref: "PermissionAction",
+ })
+ export type Action = z.infer<typeof Action>
+
+ export const Rule = z
+ .object({
+ permission: z.string(),
+ pattern: z.string(),
+ action: Action,
+ })
+ .meta({
+ ref: "PermissionRule",
+ })
+ export type Rule = z.infer<typeof Rule>
+
+ export const Ruleset = Rule.array().meta({
+ ref: "PermissionRuleset",
+ })
+ export type Ruleset = z.infer<typeof Ruleset>
+
+ export function fromConfig(permission: Config.Permission) {
+ const ruleset: Ruleset = []
+ for (const [key, value] of Object.entries(permission)) {
+ if (typeof value === "string") {
+ ruleset.push({
+ permission: key,
+ action: value,
+ pattern: "*",
+ })
+ continue
+ }
+ ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
+ }
+ return ruleset
+ }
+
+ export function merge(...rulesets: Ruleset[]): Ruleset {
+ return rulesets.flat()
+ }
+
+ export const Request = z
+ .object({
+ id: Identifier.schema("permission"),
+ sessionID: Identifier.schema("session"),
+ permission: z.string(),
+ patterns: z.string().array(),
+ metadata: z.record(z.string(), z.any()),
+ always: z.string().array(),
+ tool: z
+ .object({
+ messageID: z.string(),
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "PermissionRequest",
+ })
+
+ export type Request = z.infer<typeof Request>
+
+ export const Reply = z.enum(["once", "always", "reject"])
+ export type Reply = z.infer<typeof Reply>
+
+ export const Approval = z.object({
+ projectID: z.string(),
+ patterns: z.string().array(),
+ })
+
+ export const Event = {
+ Asked: BusEvent.define("permission.asked", Request),
+ Replied: BusEvent.define(
+ "permission.replied",
+ z.object({
+ sessionID: z.string(),
+ requestID: z.string(),
+ reply: Reply,
+ }),
+ ),
+ }
+
+ const state = Instance.state(async () => {
+ const projectID = Instance.project.id
+ const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
+
+ const pending: Record<
+ string,
+ {
+ info: Request
+ resolve: () => void
+ reject: (e: any) => void
+ }
+ > = {}
+
+ return {
+ pending,
+ approved: stored,
+ }
+ })
+
+ export const ask = fn(
+ Request.partial({ id: true }).extend({
+ ruleset: Ruleset,
+ }),
+ async (input) => {
+ const s = await state()
+ const { ruleset, ...request } = input
+ for (const pattern of request.patterns ?? []) {
+ const action = evaluate(request.permission, pattern, ruleset, s.approved)
+ log.info("evaluated", { permission: request.permission, pattern, action })
+ if (action === "deny") throw new RejectedError()
+ if (action === "ask") {
+ const id = input.id ?? Identifier.ascending("permission")
+ return new Promise<void>((resolve, reject) => {
+ const info: Request = {
+ id,
+ ...request,
+ }
+ s.pending[id] = {
+ info,
+ resolve,
+ reject,
+ }
+ Bus.publish(Event.Asked, info)
+ })
+ }
+ if (action === "allow") continue
+ }
+ },
+ )
+
+ export const reply = fn(
+ z.object({
+ requestID: Identifier.schema("permission"),
+ reply: Reply,
+ }),
+ async (input) => {
+ const s = await state()
+ const existing = s.pending[input.requestID]
+ if (!existing) return
+ delete s.pending[input.requestID]
+ Bus.publish(Event.Replied, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ reply: input.reply,
+ })
+ if (input.reply === "reject") {
+ existing.reject(new RejectedError())
+ // Reject all other pending permissions for this session
+ const sessionID = existing.info.sessionID
+ for (const [id, pending] of Object.entries(s.pending)) {
+ if (pending.info.sessionID === sessionID) {
+ delete s.pending[id]
+ Bus.publish(Event.Replied, {
+ sessionID: pending.info.sessionID,
+ requestID: pending.info.id,
+ reply: "reject",
+ })
+ pending.reject(new RejectedError())
+ }
+ }
+ return
+ }
+ if (input.reply === "once") {
+ existing.resolve()
+ return
+ }
+ if (input.reply === "always") {
+ for (const pattern of existing.info.always) {
+ s.approved.push({
+ permission: existing.info.permission,
+ pattern,
+ action: "allow",
+ })
+ }
+
+ existing.resolve()
+
+ const sessionID = existing.info.sessionID
+ for (const [id, pending] of Object.entries(s.pending)) {
+ if (pending.info.sessionID !== sessionID) continue
+ const ok = pending.info.patterns.every(
+ (pattern) => evaluate(pending.info.permission, pattern, s.approved) === "allow",
+ )
+ if (!ok) continue
+ delete s.pending[id]
+ Bus.publish(Event.Replied, {
+ sessionID: pending.info.sessionID,
+ requestID: pending.info.id,
+ reply: "always",
+ })
+ pending.resolve()
+ }
+
+ // TODO: we don't save the permission ruleset to disk yet until there's
+ // UI to manage it
+ // await Storage.write(["permission", Instance.project.id], s.approved)
+ return
+ }
+ },
+ )
+
+ export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Action {
+ const merged = merge(...rulesets)
+ log.info("evaluate", { permission, pattern, ruleset: merged })
+ const match = merged.findLast(
+ (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+ )
+ return match?.action ?? "ask"
+ }
+
+ const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
+
+ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+ const result = new Set<string>()
+ for (const tool of tools) {
+ const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+ if (evaluate(permission, "*", ruleset) === "deny") {
+ result.add(tool)
+ }
+ }
+ return result
+ }
+
+ export class RejectedError extends Error {
+ constructor(public readonly reason?: string) {
+ super(
+ reason !== undefined
+ ? reason
+ : `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
+ )
+ }
+ }
+
+ export async function list() {
+ return state().then((x) => Object.values(x.pending).map((x) => x.info))
+ }
+}
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index 79f609494..18a621fbb 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -78,6 +78,7 @@ export namespace Plugin {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
for (const hook of hooks) {
+ // @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config)
}
Bus.subscribeAll(async (input) => {
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index acd4ad5c4..9d75308c1 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -47,6 +47,7 @@ import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
+import { PermissionNext } from "@/permission/next"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
@@ -1524,6 +1525,7 @@ export namespace Server {
"/session/:sessionID/permissions/:permissionID",
describeRoute({
summary: "Respond to permission",
+ deprecated: true,
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.respond",
responses: {
@@ -1545,15 +1547,47 @@ export namespace Server {
permissionID: z.string(),
}),
),
- validator("json", z.object({ response: Permission.Response })),
+ validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
- const sessionID = params.sessionID
- const permissionID = params.permissionID
- Permission.respond({
- sessionID,
- permissionID,
- response: c.req.valid("json").response,
+ PermissionNext.reply({
+ requestID: params.permissionID,
+ reply: c.req.valid("json").response,
+ })
+ return c.json(true)
+ },
+ )
+ .post(
+ "/permission/:requestID/reply",
+ describeRoute({
+ summary: "Respond to permission request",
+ description: "Approve or deny a permission request from the AI assistant.",
+ operationId: "permission.reply",
+ responses: {
+ 200: {
+ description: "Permission processed successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ requestID: z.string(),
+ }),
+ ),
+ validator("json", z.object({ reply: PermissionNext.Reply })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const json = c.req.valid("json")
+ await PermissionNext.reply({
+ requestID: params.requestID,
+ reply: json.reply,
})
return c.json(true)
},
@@ -1569,14 +1603,14 @@ export namespace Server {
description: "List of pending permissions",
content: {
"application/json": {
- schema: resolver(Permission.Info.array()),
+ schema: resolver(PermissionNext.Request.array()),
},
},
},
},
}),
async (c) => {
- const permissions = Permission.list()
+ const permissions = await PermissionNext.list()
return c.json(permissions)
},
)
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 4285223bc..0776590d6 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -18,6 +18,7 @@ import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
+import { PermissionNext } from "@/permission/next"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -62,6 +63,7 @@ export namespace Session {
compacting: z.number().optional(),
archived: z.number().optional(),
}),
+ permission: PermissionNext.Ruleset.optional(),
revert: z
.object({
messageID: z.string(),
@@ -126,6 +128,7 @@ export namespace Session {
.object({
parentID: Identifier.schema("session").optional(),
title: z.string().optional(),
+ permission: Info.shape.permission,
})
.optional(),
async (input) => {
@@ -133,6 +136,7 @@ export namespace Session {
parentID: input?.parentID,
directory: Instance.directory,
title: input?.title,
+ permission: input?.permission,
})
},
)
@@ -174,7 +178,13 @@ export namespace Session {
})
})
- export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
+ export async function createNext(input: {
+ id?: string
+ title?: string
+ parentID?: string
+ directory: string
+ permission?: PermissionNext.Ruleset
+ }) {
const result: Info = {
id: Identifier.descending("session", input.id),
version: Installation.VERSION,
@@ -182,6 +192,7 @@ export namespace Session {
directory: input.directory,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
+ permission: input.permission,
time: {
created: Date.now(),
updated: Date.now(),
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index ccd7af1f0..fc701588d 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
-import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@/flag/flag"
+import { PermissionNext } from "@/permission/next"
export namespace LLM {
const log = Log.create({ service: "llm" })
@@ -200,13 +200,11 @@ export namespace LLM {
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
- const enabled = pipe(
- input.agent.tools,
- mergeDeep(await ToolRegistry.enabled(input.agent)),
- mergeDeep(input.user.tools ?? {}),
- )
- for (const [key, value] of Object.entries(enabled)) {
- if (value === false) delete input.tools[key]
+ const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
+ for (const tool of Object.keys(input.tools)) {
+ if (input.user.tools?.[tool] === false || disabled.has(tool)) {
+ delete input.tools[tool]
+ }
}
return input.tools
}
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 567b96479..227ca64bb 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -3,7 +3,6 @@ import { Log } from "@/util/log"
import { Identifier } from "@/id/id"
import { Session } from "."
import { Agent } from "@/agent/agent"
-import { Permission } from "@/permission"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "./summary"
import { Bus } from "@/bus"
@@ -14,6 +13,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
+import { PermissionNext } from "@/permission/next"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -152,32 +152,18 @@ export namespace SessionProcessor {
JSON.stringify(p.state.input) === JSON.stringify(value.input),
)
) {
- const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission)
- if (permission.doom_loop === "ask") {
- await Permission.ask({
- type: "doom_loop",
- pattern: value.toolName,
- sessionID: input.assistantMessage.sessionID,
- messageID: input.assistantMessage.id,
- callID: value.toolCallId,
- title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
- metadata: {
- tool: value.toolName,
- input: value.input,
- },
- })
- } else if (permission.doom_loop === "deny") {
- throw new Permission.RejectedError(
- input.assistantMessage.sessionID,
- "doom_loop",
- value.toolCallId,
- {
- tool: value.toolName,
- input: value.input,
- },
- `You seem to be stuck in a doom loop, please stop repeating the same action`,
- )
- }
+ const agent = await Agent.get(input.assistantMessage.agent)
+ await PermissionNext.ask({
+ permission: "doom_loop",
+ patterns: [value.toolName],
+ sessionID: input.assistantMessage.sessionID,
+ metadata: {
+ tool: value.toolName,
+ input: value.input,
+ },
+ always: [value.toolName],
+ ruleset: agent.permission,
+ })
}
}
break
@@ -215,7 +201,6 @@ export namespace SessionProcessor {
status: "error",
input: value.input,
error: (value.error as any).toString(),
- metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
time: {
start: match.state.time.start,
end: Date.now(),
@@ -223,7 +208,7 @@ export namespace SessionProcessor {
},
})
- if (value.error instanceof Permission.RejectedError) {
+ if (value.error instanceof PermissionNext.RejectedError) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 3171192a3..d4fef6f7a 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
import { Session } from "."
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
-import { type Tool as AITool, tool, jsonSchema } from "ai"
+import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
@@ -20,9 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { defer } from "../util/defer"
-import { clone, mergeDeep, pipe } from "remeda"
+import { clone } from "remeda"
import { ToolRegistry } from "../tool/registry"
-import { Wildcard } from "../util/wildcard"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
@@ -39,6 +38,8 @@ import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
+import { Tool } from "@/tool/tool"
+import { PermissionNext } from "@/permission/next"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
@@ -88,7 +89,12 @@ export namespace SessionPrompt {
.optional(),
agent: z.string().optional(),
noReply: z.boolean().optional(),
- tools: z.record(z.string(), z.boolean()).optional(),
+ tools: z
+ .record(z.string(), z.boolean())
+ .optional()
+ .describe(
+ "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
+ ),
system: z.string().optional(),
variant: z.string().optional(),
parts: z.array(
@@ -145,6 +151,23 @@ export namespace SessionPrompt {
const message = await createUserMessage(input)
await Session.touch(input.sessionID)
+ // this is backwards compatibility for allowing `tools` to be specified when
+ // prompting
+ const permissions: PermissionNext.Ruleset = []
+ for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
+ permissions.push({
+ permission: tool,
+ action: enabled ? "allow" : "deny",
+ pattern: "*",
+ })
+ }
+ if (permissions.length > 0) {
+ session.permission = permissions
+ await Session.update(session.id, (draft) => {
+ draft.permission = permissions
+ })
+ }
+
if (input.noReply === true) {
return message
}
@@ -240,6 +263,7 @@ export namespace SessionPrompt {
using _ = defer(() => cancel(sessionID))
let step = 0
+ const session = await Session.get(sessionID)
while (true) {
SessionStatus.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID })
@@ -276,7 +300,7 @@ export namespace SessionPrompt {
step++
if (step === 1)
ensureTitle({
- session: await Session.get(sessionID),
+ session,
modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID,
message: msgs.find((m) => m.info.role === "user")!,
@@ -350,28 +374,35 @@ export namespace SessionPrompt {
{ args: taskArgs },
)
let executionError: Error | undefined
- const result = await taskTool
- .execute(taskArgs, {
- agent: task.agent,
- messageID: assistantMessage.id,
- sessionID: sessionID,
- abort,
- async metadata(input) {
- await Session.updatePart({
- ...part,
- type: "tool",
- state: {
- ...part.state,
- ...input,
- },
- } satisfies MessageV2.ToolPart)
- },
- })
- .catch((error) => {
- executionError = error
- log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
- return undefined
- })
+ const taskAgent = await Agent.get(task.agent)
+ const taskCtx: Tool.Context = {
+ agent: task.agent,
+ messageID: assistantMessage.id,
+ sessionID: sessionID,
+ abort,
+ async metadata(input) {
+ await Session.updatePart({
+ ...part,
+ type: "tool",
+ state: {
+ ...part.state,
+ ...input,
+ },
+ } satisfies MessageV2.ToolPart)
+ },
+ async ask(req) {
+ await PermissionNext.ask({
+ ...req,
+ sessionID: sessionID,
+ ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
+ })
+ },
+ }
+ const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
+ executionError = error
+ log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
+ return undefined
+ })
await Plugin.trigger(
"tool.execute.after",
{
@@ -473,7 +504,7 @@ export namespace SessionPrompt {
// normal processing
const agent = await Agent.get(lastUser.agent)
- const maxSteps = agent.maxSteps ?? Infinity
+ const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = insertReminders({
messages: msgs,
@@ -511,7 +542,7 @@ export namespace SessionPrompt {
})
const tools = await resolveTools({
agent,
- sessionID,
+ session,
model,
tools: lastUser.tools,
processor,
@@ -581,67 +612,73 @@ export namespace SessionPrompt {
async function resolveTools(input: {
agent: Agent.Info
model: Provider.Model
- sessionID: string
+ session: Session.Info
tools?: Record<string, boolean>
processor: SessionProcessor.Info
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
- const enabledTools = pipe(
- input.agent.tools,
- mergeDeep(await ToolRegistry.enabled(input.agent)),
- mergeDeep(input.tools ?? {}),
- )
- for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
- if (Wildcard.all(item.id, enabledTools) === false) continue
+
+ const context = (args: any, options: ToolCallOptions): Tool.Context => ({
+ sessionID: input.session.id,
+ abort: options.abortSignal!,
+ messageID: input.processor.message.id,
+ callID: options.toolCallId,
+ extra: { model: input.model },
+ agent: input.agent.name,
+ metadata: async (val: { title?: string; metadata?: any }) => {
+ const match = input.processor.partFromToolCall(options.toolCallId)
+ if (match && match.state.status === "running") {
+ await Session.updatePart({
+ ...match,
+ state: {
+ title: val.title,
+ metadata: val.metadata,
+ status: "running",
+ input: args,
+ time: {
+ start: Date.now(),
+ },
+ },
+ })
+ }
+ },
+ async ask(req) {
+ await PermissionNext.ask({
+ ...req,
+ sessionID: input.session.id,
+ tool: { messageID: input.processor.message.id, callID: options.toolCallId },
+ ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
+ })
+ },
+ })
+
+ for (const item of await ToolRegistry.tools(input.model.providerID)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
description: item.description,
inputSchema: jsonSchema(schema as any),
async execute(args, options) {
+ const ctx = context(args, options)
await Plugin.trigger(
"tool.execute.before",
{
tool: item.id,
- sessionID: input.sessionID,
- callID: options.toolCallId,
+ sessionID: ctx.sessionID,
+ callID: ctx.callID,
},
{
args,
},
)
- const result = await item.execute(args, {
- sessionID: input.sessionID,
- abort: options.abortSignal!,
- messageID: input.processor.message.id,
- callID: options.toolCallId,
- extra: { model: input.model },
- agent: input.agent.name,
- metadata: async (val) => {
- const match = input.processor.partFromToolCall(options.toolCallId)
- if (match && match.state.status === "running") {
- await Session.updatePart({
- ...match,
- state: {
- title: val.title,
- metadata: val.metadata,
- status: "running",
- input: args,
- time: {
- start: Date.now(),
- },
- },
- })
- }
- },
- })
+ const result = await item.execute(args, ctx)
await Plugin.trigger(
"tool.execute.after",
{
tool: item.id,
- sessionID: input.sessionID,
- callID: options.toolCallId,
+ sessionID: ctx.sessionID,
+ callID: ctx.callID,
},
result,
)
@@ -655,31 +692,41 @@ export namespace SessionPrompt {
},
})
}
+
for (const [key, item] of Object.entries(await MCP.tools())) {
- if (Wildcard.all(key, enabledTools) === false) continue
const execute = item.execute
if (!execute) continue
// Wrap execute to add plugin hooks and format output
item.execute = async (args, opts) => {
+ const ctx = context(args, opts)
+
await Plugin.trigger(
"tool.execute.before",
{
tool: key,
- sessionID: input.sessionID,
+ sessionID: ctx.sessionID,
callID: opts.toolCallId,
},
{
args,
},
)
+
+ await ctx.ask({
+ permission: key,
+ metadata: {},
+ patterns: ["*"],
+ always: ["*"],
+ })
+
const result = await execute(args, opts)
await Plugin.trigger(
"tool.execute.after",
{
tool: key,
- sessionID: input.sessionID,
+ sessionID: ctx.sessionID,
callID: opts.toolCallId,
},
result,
@@ -694,7 +741,7 @@ export namespace SessionPrompt {
} else if (contentItem.type === "image") {
attachments.push({
id: Identifier.ascending("part"),
- sessionID: input.sessionID,
+ sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file",
mime: contentItem.mimeType,
@@ -834,14 +881,16 @@ export namespace SessionPrompt {
await ReadTool.init()
.then(async (t) => {
const model = await Provider.getModel(info.model.providerID, info.model.modelID)
- const result = await t.execute(args, {
+ const readCtx: Tool.Context = {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, model },
metadata: async () => {},
- })
+ ask: async () => {},
+ }
+ const result = await t.execute(args, readCtx)
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
@@ -893,16 +942,16 @@ export namespace SessionPrompt {
if (part.mime === "application/x-directory") {
const args = { path: filepath }
- const result = await ListTool.init().then((t) =>
- t.execute(args, {
- sessionID: input.sessionID,
- abort: new AbortController().signal,
- agent: input.agent!,
- messageID: info.id,
- extra: { bypassCwdCheck: true },
- metadata: async () => {},
- }),
- )
+ const listCtx: Tool.Context = {
+ sessionID: input.sessionID,
+ abort: new AbortController().signal,
+ agent: input.agent!,
+ messageID: info.id,
+ extra: { bypassCwdCheck: true },
+ metadata: async () => {},
+ ask: async () => {},
+ }
+ const result = await ListTool.init().then((t) => t.execute(args, listCtx))
return [
{
id: Identifier.ascending("part"),
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index e10ee3bad..f9ac12a2b 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -44,7 +44,7 @@ export namespace SystemPrompt {
`</env>`,
`<files>`,
` ${
- project.vcs === "git"
+ project.vcs === "git" && false
? await Ripgrep.tree({
cwd: Instance.directory,
limit: 200,
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 92d4ced0f..6671c939c 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -6,16 +6,15 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
-import { Agent } from "@/agent/agent"
+
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
-import { Wildcard } from "@/util/wildcard"
-import { Permission } from "@/permission"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
-import path from "path"
import { Shell } from "@/shell/shell"
+import { BashArity } from "@/permission/arity"
+
const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -81,41 +80,11 @@ export const BashTool = Tool.define("bash", async () => {
if (!tree) {
throw new Error("Failed to parse command")
}
- const agent = await Agent.get(ctx.agent)
-
- const checkExternalDirectory = async (dir: string) => {
- if (Filesystem.contains(Instance.directory, dir)) return
- const title = `This command references paths outside of ${Instance.directory}`
- if (agent.permission.external_directory === "ask") {
- await Permission.ask({
- type: "external_directory",
- pattern: [dir, path.join(dir, "*")],
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title,
- metadata: {
- command: params.command,
- },
- })
- } else if (agent.permission.external_directory === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "external_directory",
- ctx.callID,
- {
- command: params.command,
- },
- `${title} so this command is not allowed to be executed.`,
- )
- }
- }
-
- await checkExternalDirectory(cwd)
+ const directories = new Set<string>()
+ if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
+ const patterns = new Set<string>()
+ const always = new Set<string>()
- const permissions = agent.permission.bash
-
- const askPatterns = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
const command = []
@@ -150,48 +119,33 @@ export const BashTool = Tool.define("bash", async () => {
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: resolved
-
- await checkExternalDirectory(normalized)
+ directories.add(normalized)
}
}
}
- // always allow cd if it passes above check
- if (command[0] !== "cd") {
- const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions)
- if (action === "deny") {
- throw new Error(
- `The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`,
- )
- }
- if (action === "ask") {
- const pattern = (() => {
- if (command.length === 0) return
- const head = command[0]
- // Find first non-flag argument as subcommand
- const sub = command.slice(1).find((arg) => !arg.startsWith("-"))
- return sub ? `${head} ${sub} *` : `${head} *`
- })()
- if (pattern) {
- askPatterns.add(pattern)
- }
- }
+ // cd covered by above check
+ if (command.length && command[0] !== "cd") {
+ patterns.add(command.join(" "))
+ always.add(BashArity.prefix(command).join(" ") + "*")
}
}
- if (askPatterns.size > 0) {
- const patterns = Array.from(askPatterns)
- await Permission.ask({
- type: "bash",
- pattern: patterns,
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: params.command,
- metadata: {
- command: params.command,
- patterns,
- },
+ if (directories.size > 0) {
+ await ctx.ask({
+ permission: "external_directory",
+ patterns: Array.from(directories),
+ always: Array.from(directories).map((x) => x + "*"),
+ metadata: {},
+ })
+ }
+
+ if (patterns.size > 0) {
+ await ctx.ask({
+ permission: "bash",
+ patterns: Array.from(patterns),
+ always: Array.from(always),
+ metadata: {},
})
}
diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts
index 0227c06f5..369cdb450 100644
--- a/packages/opencode/src/tool/codesearch.ts
+++ b/packages/opencode/src/tool/codesearch.ts
@@ -1,8 +1,6 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./codesearch.txt"
-import { Config } from "../config/config"
-import { Permission } from "../permission"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -52,19 +50,15 @@ export const CodeSearchTool = Tool.define("codesearch", {
),
}),
async execute(params, ctx) {
- const cfg = await Config.get()
- if (cfg.permission?.webfetch === "ask")
- await Permission.ask({
- type: "codesearch",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Search code for: " + params.query,
- metadata: {
- query: params.query,
- tokensNum: params.tokensNum,
- },
- })
+ await ctx.ask({
+ permission: "codesearch",
+ patterns: [params.query],
+ always: ["*"],
+ metadata: {
+ query: params.query,
+ tokensNum: params.tokensNum,
+ },
+ })
const codeRequest: McpCodeRequest = {
jsonrpc: "2.0",
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 626799746..787282ecd 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -8,14 +8,12 @@ import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
-import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
import { Snapshot } from "@/snapshot"
const MAX_DIAGNOSTICS_PER_FILE = 20
@@ -41,36 +39,18 @@ export const EditTool = Tool.define("edit", {
throw new Error("oldString and newString must be different")
}
- const agent = await Agent.get(ctx.agent)
-
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
- if (agent.permission.external_directory === "ask") {
- await Permission.ask({
- type: "external_directory",
- pattern: [parentDir, path.join(parentDir, "*")],
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Edit file outside working directory: ${filePath}`,
- metadata: {
- filepath: filePath,
- parentDir,
- },
- })
- } else if (agent.permission.external_directory === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "external_directory",
- ctx.callID,
- {
- filepath: filePath,
- parentDir,
- },
- `File ${filePath} is not in the current working directory`,
- )
- }
+ await ctx.ask({
+ permission: "external_directory",
+ patterns: [parentDir, path.join(parentDir, "*")],
+ always: [parentDir + "/*"],
+ metadata: {
+ filepath: filePath,
+ parentDir,
+ },
+ })
}
let diff = ""
@@ -80,19 +60,15 @@ export const EditTool = Tool.define("edit", {
if (params.oldString === "") {
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
- if (agent.permission.edit === "ask") {
- await Permission.ask({
- type: "edit",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Edit this file: " + filePath,
- metadata: {
- filePath,
- diff,
- },
- })
- }
+ await ctx.ask({
+ permission: "edit",
+ patterns: [path.relative(Instance.worktree, filePath)],
+ always: ["*"],
+ metadata: {
+ filepath: filePath,
+ diff,
+ },
+ })
await Bun.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
@@ -112,19 +88,15 @@ export const EditTool = Tool.define("edit", {
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
- if (agent.permission.edit === "ask") {
- await Permission.ask({
- type: "edit",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Edit this file: " + filePath,
- metadata: {
- filePath,
- diff,
- },
- })
- }
+ await ctx.ask({
+ permission: "edit",
+ patterns: [path.relative(Instance.worktree, filePath)],
+ always: ["*"],
+ metadata: {
+ filepath: filePath,
+ diff,
+ },
+ })
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
@@ -137,6 +109,26 @@ export const EditTool = Tool.define("edit", {
FileTime.read(ctx.sessionID, filePath)
})
+ const filediff: Snapshot.FileDiff = {
+ file: filePath,
+ before: contentOld,
+ after: contentNew,
+ additions: 0,
+ deletions: 0,
+ }
+ for (const change of diffLines(contentOld, contentNew)) {
+ if (change.added) filediff.additions += change.count || 0
+ if (change.removed) filediff.deletions += change.count || 0
+ }
+
+ ctx.metadata({
+ metadata: {
+ diff,
+ filediff,
+ diagnostics: {},
+ },
+ })
+
let output = ""
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
@@ -150,18 +142,6 @@ export const EditTool = Tool.define("edit", {
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
}
- const filediff: Snapshot.FileDiff = {
- file: filePath,
- before: contentOld,
- after: contentNew,
- additions: 0,
- deletions: 0,
- }
- for (const change of diffLines(contentOld, contentNew)) {
- if (change.added) filediff.additions += change.count || 0
- if (change.removed) filediff.deletions += change.count || 0
- }
-
return {
metadata: {
diagnostics,
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index 11c12f19a..0c643796d 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -16,7 +16,17 @@ export const GlobTool = Tool.define("glob", {
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
- async execute(params) {
+ async execute(params, ctx) {
+ await ctx.ask({
+ permission: "glob",
+ patterns: [params.pattern],
+ always: ["*"],
+ metadata: {
+ pattern: params.pattern,
+ path: params.path,
+ },
+ })
+
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts
index d73bc1616..4cbc5347f 100644
--- a/packages/opencode/src/tool/grep.ts
+++ b/packages/opencode/src/tool/grep.ts
@@ -14,11 +14,22 @@ export const GrepTool = Tool.define("grep", {
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
- async execute(params) {
+ async execute(params, ctx) {
if (!params.pattern) {
throw new Error("pattern is required")
}
+ await ctx.ask({
+ permission: "grep",
+ patterns: [params.pattern],
+ always: ["*"],
+ metadata: {
+ pattern: params.pattern,
+ path: params.path,
+ include: params.include,
+ },
+ })
+
const searchPath = params.path || Instance.directory
const rgPath = await Ripgrep.filepath()
diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts
index 95c36e745..b8638b3e9 100644
--- a/packages/opencode/src/tool/ls.ts
+++ b/packages/opencode/src/tool/ls.ts
@@ -40,9 +40,18 @@ export const ListTool = Tool.define("list", {
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
- async execute(params) {
+ async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
+ await ctx.ask({
+ permission: "list",
+ patterns: [searchPath],
+ always: ["*"],
+ metadata: {
+ path: searchPath,
+ },
+ })
+
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) {
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index 2a15ed7e3..df4692bf6 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -26,7 +26,14 @@ export const LspTool = Tool.define("lsp", {
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
- execute: async (args) => {
+ execute: async (args, ctx) => {
+ await ctx.ask({
+ permission: "lsp",
+ patterns: ["*"],
+ always: ["*"],
+ metadata: {},
+ })
+
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
const uri = pathToFileURL(file).href
const position = {
diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts
index 93888f60b..62d9f70f2 100644
--- a/packages/opencode/src/tool/patch.ts
+++ b/packages/opencode/src/tool/patch.ts
@@ -3,11 +3,9 @@ import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
-import { Permission } from "../permission"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
import { Patch } from "../patch"
import { Filesystem } from "../util/filesystem"
import { createTwoFilesPatch } from "diff"
@@ -39,7 +37,6 @@ export const PatchTool = Tool.define("patch", {
}
// Validate file paths and check permissions
- const agent = await Agent.get(ctx.agent)
const fileChanges: Array<{
filePath: string
oldContent: string
@@ -55,31 +52,15 @@ export const PatchTool = Tool.define("patch", {
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
- if (agent.permission.external_directory === "ask") {
- await Permission.ask({
- type: "external_directory",
- pattern: [parentDir, path.join(parentDir, "*")],
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Patch file outside working directory: ${filePath}`,
- metadata: {
- filepath: filePath,
- parentDir,
- },
- })
- } else if (agent.permission.external_directory === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "external_directory",
- ctx.callID,
- {
- filepath: filePath,
- parentDir,
- },
- `File ${filePath} is not in the current working directory`,
- )
- }
+ await ctx.ask({
+ permission: "external_directory",
+ patterns: [parentDir, path.join(parentDir, "*")],
+ always: [parentDir + "/*"],
+ metadata: {
+ filepath: filePath,
+ parentDir,
+ },
+ })
}
switch (hunk.type) {
@@ -152,18 +133,14 @@ export const PatchTool = Tool.define("patch", {
}
// Check permissions if needed
- if (agent.permission.edit === "ask") {
- await Permission.ask({
- type: "edit",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Apply patch to ${fileChanges.length} files`,
- metadata: {
- diff: totalDiff,
- },
- })
- }
+ await ctx.ask({
+ permission: "edit",
+ patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
+ always: ["*"],
+ metadata: {
+ diff: totalDiff,
+ },
+ })
// Apply the changes
const changedFiles: string[] = []
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index fd81c4864..847fe3ebe 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -8,8 +8,6 @@ import DESCRIPTION from "./read.txt"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
-import { Permission } from "../permission"
-import { Agent } from "@/agent/agent"
import { iife } from "@/util/iife"
const DEFAULT_READ_LIMIT = 2000
@@ -28,37 +26,27 @@ export const ReadTool = Tool.define("read", {
filepath = path.join(process.cwd(), filepath)
}
const title = path.relative(Instance.worktree, filepath)
- const agent = await Agent.get(ctx.agent)
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
- if (agent.permission.external_directory === "ask") {
- await Permission.ask({
- type: "external_directory",
- pattern: [parentDir, path.join(parentDir, "*")],
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Access file outside working directory: ${filepath}`,
- metadata: {
- filepath,
- parentDir,
- },
- })
- } else if (agent.permission.external_directory === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "external_directory",
- ctx.callID,
- {
- filepath: filepath,
- parentDir,
- },
- `File ${filepath} is not in the current working directory`,
- )
- }
+ await ctx.ask({
+ permission: "external_directory",
+ patterns: [parentDir],
+ always: [parentDir + "/*"],
+ metadata: {
+ filepath,
+ parentDir,
+ },
+ })
}
+ await ctx.ask({
+ permission: "read",
+ patterns: [filepath],
+ always: ["*"],
+ metadata: {},
+ })
+
const block = iife(() => {
const basename = path.basename(filepath)
const whitelist = [".env.sample", ".env.example", ".example", ".env.template"]
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index f975d52a0..db5152847 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -2,7 +2,6 @@ import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
-import { ListTool } from "./ls"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
@@ -135,27 +134,4 @@ export namespace ToolRegistry {
)
return result
}
-
- export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
- const result: Record<string, boolean> = {}
-
- if (agent.permission.edit === "deny") {
- result["edit"] = false
- result["write"] = false
- }
- if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
- result["bash"] = false
- }
- if (agent.permission.webfetch === "deny") {
- result["webfetch"] = false
- result["codesearch"] = false
- result["websearch"] = false
- }
- // Disable skill tool if all skills are denied
- if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
- result["skill"] = false
- }
-
- return result
- }
}
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index b56276f61..00a081eac 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -2,21 +2,13 @@ import path from "path"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
-import { Agent } from "../agent/agent"
-import { Permission } from "../permission"
-import { Wildcard } from "../util/wildcard"
import { ConfigMarkdown } from "../config/markdown"
-const parameters = z.object({
- name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
-})
-
-export const SkillTool: Tool.Info<typeof parameters> = {
- id: "skill",
- async init(ctx) {
- const skills = await Skill.all()
+export const SkillTool = Tool.define("skill", async () => {
+ const skills = await Skill.all()
- // Filter skills by agent permissions if agent provided
+ // Filter skills by agent permissions if agent provided
+ /*
let accessibleSkills = skills
if (ctx?.agent) {
const permissions = ctx.agent.permission.skill
@@ -25,81 +17,61 @@ export const SkillTool: Tool.Info<typeof parameters> = {
return action !== "deny"
})
}
+ */
- const description =
- accessibleSkills.length === 0
- ? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
- : [
- "Load a skill to get detailed instructions for a specific task.",
- "Skills provide specialized knowledge and step-by-step guidance.",
- "Use this when a task matches an available skill's description.",
- "<available_skills>",
- ...accessibleSkills.flatMap((skill) => [
- ` <skill>`,
- ` <name>${skill.name}</name>`,
- ` <description>${skill.description}</description>`,
- ` </skill>`,
- ]),
- "</available_skills>",
- ].join(" ")
-
- return {
- description,
- parameters,
- async execute(params, ctx) {
- const agent = await Agent.get(ctx.agent)
-
- const skill = await Skill.get(params.name)
-
- if (!skill) {
- const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
- throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
- }
+ const description =
+ skills.length === 0
+ ? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
+ : [
+ "Load a skill to get detailed instructions for a specific task.",
+ "Skills provide specialized knowledge and step-by-step guidance.",
+ "Use this when a task matches an available skill's description.",
+ "<available_skills>",
+ ...skills.flatMap((skill) => [
+ ` <skill>`,
+ ` <name>${skill.name}</name>`,
+ ` <description>${skill.description}</description>`,
+ ` </skill>`,
+ ]),
+ "</available_skills>",
+ ].join(" ")
- // Check permission using Wildcard.all on the skill name
- const permissions = agent.permission.skill
- const action = Wildcard.all(params.name, permissions)
+ return {
+ description,
+ parameters: z.object({
+ name: z
+ .string()
+ .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
+ }),
+ async execute(params, ctx) {
+ const skill = await Skill.get(params.name)
- if (action === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "skill",
- ctx.callID,
- { skill: params.name },
- `Access to skill "${params.name}" is denied for agent "${agent.name}".`,
- )
- }
+ if (!skill) {
+ const available = Skill.all().then((x) => Object.keys(x).join(", "))
+ throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
+ }
- if (action === "ask") {
- await Permission.ask({
- type: "skill",
- pattern: params.name,
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Load skill: ${skill.name}`,
- metadata: { name: skill.name, description: skill.description },
- })
- }
-
- // Load and parse skill content
- const parsed = await ConfigMarkdown.parse(skill.location)
- const dir = path.dirname(skill.location)
+ await ctx.ask({
+ permission: "skill",
+ patterns: [params.name],
+ always: [params.name],
+ metadata: {},
+ })
+ // Load and parse skill content
+ const parsed = await ConfigMarkdown.parse(skill.location)
+ const dir = path.dirname(skill.location)
- // Format output similar to plugin pattern
- const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join(
- "\n",
- )
+ // Format output similar to plugin pattern
+ const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
- return {
- title: `Loaded skill: ${skill.name}`,
- output,
- metadata: {
- name: skill.name,
- dir,
- },
- }
- },
- }
- },
-}
+ return {
+ title: `Loaded skill: ${skill.name}`,
+ output,
+ metadata: {
+ name: skill.name,
+ dir,
+ },
+ }
+ },
+ }
+})
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index bc93f497a..112edc3dc 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -29,6 +29,17 @@ export const TaskTool = Tool.define("task", async () => {
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
+ const config = await Config.get()
+ await ctx.ask({
+ permission: "task",
+ patterns: [params.subagent_type],
+ always: ["*"],
+ metadata: {
+ description: params.description,
+ subagent_type: params.subagent_type,
+ },
+ })
+
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const session = await iife(async () => {
@@ -40,6 +51,28 @@ export const TaskTool = Tool.define("task", async () => {
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
+ permission: [
+ {
+ permission: "todowrite",
+ pattern: "*",
+ action: "deny",
+ },
+ {
+ permission: "todoread",
+ pattern: "*",
+ action: "deny",
+ },
+ {
+ permission: "task",
+ pattern: "*",
+ action: "deny",
+ },
+ ...(config.experimental?.primary_tools?.map((t) => ({
+ pattern: "*",
+ action: "allow" as const,
+ permission: t,
+ })) ?? []),
+ ],
})
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
@@ -88,7 +121,6 @@ export const TaskTool = Tool.define("task", async () => {
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
- const config = await Config.get()
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
@@ -102,7 +134,6 @@ export const TaskTool = Tool.define("task", async () => {
todoread: false,
task: false,
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
- ...agent.tools,
},
parts: promptParts,
})
diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts
index cea8d5322..440f1563c 100644
--- a/packages/opencode/src/tool/todo.ts
+++ b/packages/opencode/src/tool/todo.ts
@@ -8,9 +8,16 @@ export const TodoWriteTool = Tool.define("todowrite", {
parameters: z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
}),
- async execute(params, opts) {
+ async execute(params, ctx) {
+ await ctx.ask({
+ permission: "todowrite",
+ patterns: ["*"],
+ always: ["*"],
+ metadata: {},
+ })
+
await Todo.update({
- sessionID: opts.sessionID,
+ sessionID: ctx.sessionID,
todos: params.todos,
})
return {
@@ -26,8 +33,15 @@ export const TodoWriteTool = Tool.define("todowrite", {
export const TodoReadTool = Tool.define("todoread", {
description: "Use this tool to read your todo list",
parameters: z.object({}),
- async execute(_params, opts) {
- const todos = await Todo.get(opts.sessionID)
+ async execute(_params, ctx) {
+ await ctx.ask({
+ permission: "todoread",
+ patterns: ["*"],
+ always: ["*"],
+ metadata: {},
+ })
+
+ const todos = await Todo.get(ctx.sessionID)
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index acee24902..434a3d426 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -1,6 +1,7 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
+import type { PermissionNext } from "../permission/next"
export namespace Tool {
interface Metadata {
@@ -19,6 +20,7 @@ export namespace Tool {
callID?: string
extra?: { [key: string]: any }
metadata(input: { title?: string; metadata?: M }): void
+ ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts
index cf1940bf8..634c68f4e 100644
--- a/packages/opencode/src/tool/webfetch.ts
+++ b/packages/opencode/src/tool/webfetch.ts
@@ -2,8 +2,6 @@ import z from "zod"
import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
-import { Config } from "../config/config"
-import { Permission } from "../permission"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -25,20 +23,16 @@ export const WebFetchTool = Tool.define("webfetch", {
throw new Error("URL must start with http:// or https://")
}
- const cfg = await Config.get()
- if (cfg.permission?.webfetch === "ask")
- await Permission.ask({
- type: "webfetch",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Fetch content from: " + params.url,
- metadata: {
- url: params.url,
- format: params.format,
- timeout: params.timeout,
- },
- })
+ await ctx.ask({
+ permission: "webfetch",
+ patterns: [params.url],
+ always: ["*"],
+ metadata: {
+ url: params.url,
+ format: params.format,
+ timeout: params.timeout,
+ },
+ })
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts
index 4064d12f3..f6df36f10 100644
--- a/packages/opencode/src/tool/websearch.ts
+++ b/packages/opencode/src/tool/websearch.ts
@@ -1,8 +1,6 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./websearch.txt"
-import { Config } from "../config/config"
-import { Permission } from "../permission"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -59,22 +57,18 @@ export const WebSearchTool = Tool.define("websearch", {
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
async execute(params, ctx) {
- const cfg = await Config.get()
- if (cfg.permission?.webfetch === "ask")
- await Permission.ask({
- type: "websearch",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Search web for: " + params.query,
- metadata: {
- query: params.query,
- numResults: params.numResults,
- livecrawl: params.livecrawl,
- type: params.type,
- contextMaxCharacters: params.contextMaxCharacters,
- },
- })
+ await ctx.ask({
+ permission: "websearch",
+ patterns: [params.query],
+ always: ["*"],
+ metadata: {
+ query: params.query,
+ numResults: params.numResults,
+ livecrawl: params.livecrawl,
+ type: params.type,
+ contextMaxCharacters: params.contextMaxCharacters,
+ },
+ })
const searchRequest: McpSearchRequest = {
jsonrpc: "2.0",
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index a0e87299a..a0ca6b14f 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -2,14 +2,14 @@ import z from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
-import { Permission } from "../permission"
+import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
+import { trimDiff } from "./edit"
const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -21,55 +21,29 @@ export const WriteTool = Tool.define("write", {
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
- const agent = await Agent.get(ctx.agent)
-
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
+ /* TODO
if (!Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
- if (agent.permission.external_directory === "ask") {
- await Permission.ask({
- type: "external_directory",
- pattern: [parentDir, path.join(parentDir, "*")],
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Write file outside working directory: ${filepath}`,
- metadata: {
- filepath,
- parentDir,
- },
- })
- } else if (agent.permission.external_directory === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "external_directory",
- ctx.callID,
- {
- filepath: filepath,
- parentDir,
- },
- `File ${filepath} is not in the current working directory`,
- )
- }
+ ...
}
+ */
const file = Bun.file(filepath)
const exists = await file.exists()
+ const contentOld = exists ? await file.text() : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)
- if (agent.permission.edit === "ask")
- await Permission.ask({
- type: "write",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
- metadata: {
- filePath: filepath,
- content: params.content,
- exists,
- },
- })
+ const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
+ await ctx.ask({
+ permission: "edit",
+ patterns: [path.relative(Instance.worktree, filepath)],
+ always: ["*"],
+ metadata: {
+ filepath,
+ diff,
+ },
+ })
await Bun.write(filepath, params.content)
await Bus.publish(File.Event.Edited, {
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 222bf8367..c11ebfbf0 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -1,11 +1,16 @@
import { test, expect } from "bun:test"
-import path from "path"
-import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
+import { PermissionNext } from "../../src/permission/next"
-test("loads built-in agents when no custom agents configured", async () => {
+// Helper to evaluate permission for a tool with wildcard pattern
+function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
+ if (!agent) return undefined
+ return PermissionNext.evaluate(permission, "*", agent.permission)
+}
+
+test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -14,133 +19,430 @@ test("loads built-in agents when no custom agents configured", async () => {
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
+ expect(names).toContain("general")
+ expect(names).toContain("explore")
+ expect(names).toContain("compaction")
+ expect(names).toContain("title")
+ expect(names).toContain("summary")
+ },
+ })
+})
+
+test("build agent has correct default properties", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build).toBeDefined()
+ expect(build?.mode).toBe("primary")
+ expect(build?.native).toBe(true)
+ expect(evalPerm(build, "edit")).toBe("allow")
+ expect(evalPerm(build, "bash")).toBe("allow")
+ },
+ })
+})
+
+test("plan agent denies edits except .opencode/plan/*", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const plan = await Agent.get("plan")
+ expect(plan).toBeDefined()
+ // Wildcard is denied
+ expect(evalPerm(plan, "edit")).toBe("deny")
+ // But specific path is allowed
+ expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow")
+ },
+ })
+})
+
+test("explore agent denies edit and write", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const explore = await Agent.get("explore")
+ expect(explore).toBeDefined()
+ expect(explore?.mode).toBe("subagent")
+ expect(evalPerm(explore, "edit")).toBe("deny")
+ expect(evalPerm(explore, "write")).toBe("deny")
+ expect(evalPerm(explore, "todoread")).toBe("deny")
+ expect(evalPerm(explore, "todowrite")).toBe("deny")
+ },
+ })
+})
+
+test("general agent denies todo tools", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const general = await Agent.get("general")
+ expect(general).toBeDefined()
+ expect(general?.mode).toBe("subagent")
+ expect(general?.hidden).toBe(true)
+ expect(evalPerm(general, "todoread")).toBe("deny")
+ expect(evalPerm(general, "todowrite")).toBe("deny")
+ },
+ })
+})
+
+test("compaction agent denies all permissions", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const compaction = await Agent.get("compaction")
+ expect(compaction).toBeDefined()
+ expect(compaction?.hidden).toBe(true)
+ expect(evalPerm(compaction, "bash")).toBe("deny")
+ expect(evalPerm(compaction, "edit")).toBe("deny")
+ expect(evalPerm(compaction, "read")).toBe("deny")
},
})
})
-test("custom subagent works alongside built-in primary agents", async () => {
+test("custom agent from config creates new agent", async () => {
await using tmp = await tmpdir({
- init: async (dir) => {
- const opencodeDir = path.join(dir, ".opencode")
- await fs.mkdir(opencodeDir, { recursive: true })
- const agentDir = path.join(opencodeDir, "agent")
- await fs.mkdir(agentDir, { recursive: true })
+ config: {
+ agent: {
+ my_custom_agent: {
+ model: "openai/gpt-4",
+ description: "My custom agent",
+ temperature: 0.5,
+ top_p: 0.9,
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const custom = await Agent.get("my_custom_agent")
+ expect(custom).toBeDefined()
+ expect(custom?.model?.providerID).toBe("openai")
+ expect(custom?.model?.modelID).toBe("gpt-4")
+ expect(custom?.description).toBe("My custom agent")
+ expect(custom?.temperature).toBe(0.5)
+ expect(custom?.topP).toBe(0.9)
+ expect(custom?.native).toBe(false)
+ expect(custom?.mode).toBe("all")
+ },
+ })
+})
+
+test("custom agent config overrides native agent properties", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ build: {
+ model: "anthropic/claude-3",
+ description: "Custom build agent",
+ temperature: 0.7,
+ color: "#FF0000",
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build).toBeDefined()
+ expect(build?.model?.providerID).toBe("anthropic")
+ expect(build?.model?.modelID).toBe("claude-3")
+ expect(build?.description).toBe("Custom build agent")
+ expect(build?.temperature).toBe(0.7)
+ expect(build?.color).toBe("#FF0000")
+ expect(build?.native).toBe(true)
+ },
+ })
+})
- await Bun.write(
- path.join(agentDir, "helper.md"),
- `---
-model: test/model
-mode: subagent
----
-Helper subagent prompt`,
- )
+test("agent disable removes agent from list", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ explore: { disable: true },
+ },
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
+ const explore = await Agent.get("explore")
+ expect(explore).toBeUndefined()
const agents = await Agent.list()
- const helper = agents.find((a) => a.name === "helper")
- expect(helper).toBeDefined()
- expect(helper?.mode).toBe("subagent")
+ const names = agents.map((a) => a.name)
+ expect(names).not.toContain("explore")
+ },
+ })
+})
- // Built-in primary agents should still exist
- const build = agents.find((a) => a.name === "build")
+test("agent permission config merges with defaults", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ build: {
+ permission: {
+ bash: {
+ "rm -rf *": "deny",
+ },
+ },
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
expect(build).toBeDefined()
- expect(build?.mode).toBe("primary")
+ // Specific pattern is denied
+ expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny")
+ // Edit still allowed
+ expect(evalPerm(build, "edit")).toBe("allow")
+ },
+ })
+})
+
+test("global permission config applies to all agents", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ permission: {
+ bash: "deny",
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build).toBeDefined()
+ expect(evalPerm(build, "bash")).toBe("deny")
},
})
})
-test("throws error when all primary agents are disabled", async () => {
+test("agent steps/maxSteps config sets steps property", async () => {
await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- agent: {
- build: { disable: true },
- plan: { disable: true },
+ config: {
+ agent: {
+ build: { steps: 50 },
+ plan: { maxSteps: 100 },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ const plan = await Agent.get("plan")
+ expect(build?.steps).toBe(50)
+ expect(plan?.steps).toBe(100)
+ },
+ })
+})
+
+test("agent mode can be overridden", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ explore: { mode: "primary" },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const explore = await Agent.get("explore")
+ expect(explore?.mode).toBe("primary")
+ },
+ })
+})
+
+test("agent name can be overridden", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ build: { name: "Builder" },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build?.name).toBe("Builder")
+ },
+ })
+})
+
+test("agent prompt can be set from config", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ build: { prompt: "Custom system prompt" },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build?.prompt).toBe("Custom system prompt")
+ },
+ })
+})
+
+test("unknown agent properties are placed into options", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ build: {
+ random_property: "hello",
+ another_random: 123,
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build?.options.random_property).toBe("hello")
+ expect(build?.options.another_random).toBe(123)
+ },
+ })
+})
+
+test("agent options merge correctly", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ build: {
+ options: {
+ custom_option: true,
+ another_option: "value",
},
- }),
- )
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(build?.options.custom_option).toBe(true)
+ expect(build?.options.another_option).toBe("value")
+ },
+ })
+})
+
+test("multiple custom agents can be defined", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ agent_a: {
+ description: "Agent A",
+ mode: "subagent",
+ },
+ agent_b: {
+ description: "Agent B",
+ mode: "primary",
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const agentA = await Agent.get("agent_a")
+ const agentB = await Agent.get("agent_b")
+ expect(agentA?.description).toBe("Agent A")
+ expect(agentA?.mode).toBe("subagent")
+ expect(agentB?.description).toBe("Agent B")
+ expect(agentB?.mode).toBe("primary")
+ },
+ })
+})
+
+test("Agent.get returns undefined for non-existent agent", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const nonExistent = await Agent.get("does_not_exist")
+ expect(nonExistent).toBeUndefined()
+ },
+ })
+})
+
+test("default permission includes doom_loop and external_directory as ask", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ expect(evalPerm(build, "doom_loop")).toBe("ask")
+ expect(evalPerm(build, "external_directory")).toBe("ask")
},
})
+})
+
+test("webfetch is allowed by default", async () => {
+ await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
- try {
- await Agent.list()
- expect(true).toBe(false) // should not reach here
- } catch (e: any) {
- expect(e.data?.message).toContain("No primary agents are available")
- }
+ const build = await Agent.get("build")
+ expect(evalPerm(build, "webfetch")).toBe("allow")
},
})
})
-test("does not throw when at least one primary agent remains", async () => {
+test("legacy tools config converts to permissions", async () => {
await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- agent: {
- build: { disable: true },
+ config: {
+ agent: {
+ build: {
+ tools: {
+ bash: false,
+ read: false,
},
- }),
- )
+ },
+ },
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const agents = await Agent.list()
- const plan = agents.find((a) => a.name === "plan")
- expect(plan).toBeDefined()
- expect(plan?.mode).toBe("primary")
+ const build = await Agent.get("build")
+ expect(evalPerm(build, "bash")).toBe("deny")
+ expect(evalPerm(build, "read")).toBe("deny")
},
})
})
-test("custom primary agent satisfies requirement when built-ins disabled", async () => {
+test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
await using tmp = await tmpdir({
- init: async (dir) => {
- const opencodeDir = path.join(dir, ".opencode")
- await fs.mkdir(opencodeDir, { recursive: true })
- const agentDir = path.join(opencodeDir, "agent")
- await fs.mkdir(agentDir, { recursive: true })
-
- await Bun.write(
- path.join(agentDir, "custom.md"),
- `---
-model: test/model
-mode: primary
----
-Custom primary agent`,
- )
-
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- agent: {
- build: { disable: true },
- plan: { disable: true },
+ config: {
+ agent: {
+ build: {
+ tools: {
+ write: false,
},
- }),
- )
+ },
+ },
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const agents = await Agent.list()
- const custom = agents.find((a) => a.name === "custom")
- expect(custom).toBeDefined()
- expect(custom?.mode).toBe("primary")
+ const build = await Agent.get("build")
+ expect(evalPerm(build, "edit")).toBe("deny")
},
})
})
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 8871fd50b..c2ed3abe8 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -205,11 +205,13 @@ test("handles agent configuration", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.agent?.["test_agent"]).toEqual({
- model: "test/model",
- temperature: 0.7,
- description: "test agent",
- })
+ expect(config.agent?.["test_agent"]).toEqual(
+ expect.objectContaining({
+ model: "test/model",
+ temperature: 0.7,
+ description: "test agent",
+ }),
+ )
},
})
})
@@ -292,6 +294,8 @@ test("migrates mode field to agent field", async () => {
model: "test/model",
temperature: 0.5,
mode: "primary",
+ options: {},
+ permission: {},
})
},
})
@@ -318,11 +322,13 @@ Test agent prompt`,
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.agent?.["test"]).toEqual({
- name: "test",
- model: "test/model",
- prompt: "Test agent prompt",
- })
+ expect(config.agent?.["test"]).toEqual(
+ expect.objectContaining({
+ name: "test",
+ model: "test/model",
+ prompt: "Test agent prompt",
+ }),
+ )
},
})
})
@@ -472,7 +478,7 @@ Helper subagent prompt`,
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.agent?.["helper"]).toEqual({
+ expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
mode: "subagent",
@@ -534,13 +540,142 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
})
})
-test("compaction config defaults to true when not specified", async () => {
+// Legacy tools migration tests
+
+test("migrates legacy tools config to permissions - allow", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ agent: {
+ test: {
+ tools: {
+ bash: true,
+ read: true,
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ bash: "allow",
+ read: "allow",
+ })
+ },
+ })
+})
+
+test("migrates legacy tools config to permissions - deny", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ agent: {
+ test: {
+ tools: {
+ bash: false,
+ webfetch: false,
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ bash: "deny",
+ webfetch: "deny",
+ })
+ },
+ })
+})
+
+test("migrates legacy write tool to edit permission", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ agent: {
+ test: {
+ tools: {
+ write: true,
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ edit: "allow",
+ })
+ },
+ })
+})
+
+test("migrates legacy edit tool to edit permission", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ agent: {
+ test: {
+ tools: {
+ edit: false,
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ edit: "deny",
+ })
+ },
+ })
+})
+
+test("migrates legacy patch tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
+ agent: {
+ test: {
+ tools: {
+ patch: true,
+ },
+ },
+ },
}),
)
},
@@ -549,21 +684,26 @@ test("compaction config defaults to true when not specified", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- // When not specified, compaction should be undefined (defaults handled in usage)
- expect(config.compaction).toBeUndefined()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ edit: "allow",
+ })
},
})
})
-test("compaction config can disable auto compaction", async () => {
+test("migrates legacy multiedit tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
- compaction: {
- auto: false,
+ agent: {
+ test: {
+ tools: {
+ multiedit: false,
+ },
+ },
},
}),
)
@@ -573,21 +713,29 @@ test("compaction config can disable auto compaction", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.compaction?.auto).toBe(false)
- expect(config.compaction?.prune).toBeUndefined()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ edit: "deny",
+ })
},
})
})
-test("compaction config can disable prune", async () => {
+test("migrates mixed legacy tools config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
- compaction: {
- prune: false,
+ agent: {
+ test: {
+ tools: {
+ bash: true,
+ write: true,
+ read: false,
+ webfetch: true,
+ },
+ },
},
}),
)
@@ -597,22 +745,32 @@ test("compaction config can disable prune", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.compaction?.prune).toBe(false)
- expect(config.compaction?.auto).toBeUndefined()
+ expect(config.agent?.["test"]?.permission).toEqual({
+ bash: "allow",
+ edit: "allow",
+ read: "deny",
+ webfetch: "allow",
+ })
},
})
})
-test("compaction config can disable both auto and prune", async () => {
+test("merges legacy tools with existing permission config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
- compaction: {
- auto: false,
- prune: false,
+ agent: {
+ test: {
+ permission: {
+ glob: "allow",
+ },
+ tools: {
+ bash: true,
+ },
+ },
},
}),
)
@@ -622,8 +780,10 @@ test("compaction config can disable both auto and prune", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.compaction?.auto).toBe(false)
- expect(config.compaction?.prune).toBe(false)
+ expect(config.agent?.["test"]?.permission).toEqual({
+ glob: "allow",
+ bash: "allow",
+ })
},
})
})
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index 31cf0ae99..ed8c5e344 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/packages/opencode/test/fixture/fixture.ts
@@ -2,6 +2,7 @@ import { $ } from "bun"
import * as fs from "fs/promises"
import os from "os"
import path from "path"
+import type { Config } from "../../src/config/config"
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@@ -10,6 +11,7 @@ function sanitizePath(p: string): string {
type TmpDirOptions<T> = {
git?: boolean
+ config?: Partial<Config.Info>
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
@@ -20,6 +22,15 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await $`git init`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
+ if (options?.config) {
+ await Bun.write(
+ path.join(dirpath, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ ...options.config,
+ }),
+ )
+ }
const extra = await options?.init?.(dirpath)
const realpath = sanitizePath(await fs.realpath(dirpath))
const result = {
diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts
new file mode 100644
index 000000000..634e41e72
--- /dev/null
+++ b/packages/opencode/test/permission/arity.test.ts
@@ -0,0 +1,33 @@
+import { test, expect } from "bun:test"
+import { BashArity } from "../../src/permission/arity"
+
+test("arity 1 - unknown commands default to first token", () => {
+ expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"])
+ expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"])
+})
+
+test("arity 2 - two token commands", () => {
+ expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"])
+ expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"])
+})
+
+test("arity 3 - three token commands", () => {
+ expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"])
+ expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"])
+})
+
+test("longest match wins - nested prefixes", () => {
+ expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"])
+ expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"])
+})
+
+test("exact length matches", () => {
+ expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"])
+ expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"])
+})
+
+test("edge cases", () => {
+ expect(BashArity.prefix([])).toEqual([])
+ expect(BashArity.prefix(["single"])).toEqual(["single"])
+ expect(BashArity.prefix(["git"])).toEqual(["git"])
+})
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
new file mode 100644
index 000000000..31af4cd45
--- /dev/null
+++ b/packages/opencode/test/permission/next.test.ts
@@ -0,0 +1,652 @@
+import { test, expect } from "bun:test"
+import { PermissionNext } from "../../src/permission/next"
+import { Instance } from "../../src/project/instance"
+import { Storage } from "../../src/storage/storage"
+import { tmpdir } from "../fixture/fixture"
+
+// fromConfig tests
+
+test("fromConfig - string value becomes wildcard rule", () => {
+ const result = PermissionNext.fromConfig({ bash: "allow" })
+ expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
+})
+
+test("fromConfig - object value converts to rules array", () => {
+ const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
+ expect(result).toEqual([
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm", action: "deny" },
+ ])
+})
+
+test("fromConfig - mixed string and object values", () => {
+ const result = PermissionNext.fromConfig({
+ bash: { "*": "allow", rm: "deny" },
+ edit: "allow",
+ webfetch: "ask",
+ })
+ expect(result).toEqual([
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm", action: "deny" },
+ { permission: "edit", pattern: "*", action: "allow" },
+ { permission: "webfetch", pattern: "*", action: "ask" },
+ ])
+})
+
+test("fromConfig - empty object", () => {
+ const result = PermissionNext.fromConfig({})
+ expect(result).toEqual([])
+})
+
+// merge tests
+
+test("merge - simple concatenation", () => {
+ const result = PermissionNext.merge(
+ [{ permission: "bash", pattern: "*", action: "allow" }],
+ [{ permission: "bash", pattern: "*", action: "deny" }],
+ )
+ expect(result).toEqual([
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "*", action: "deny" },
+ ])
+})
+
+test("merge - adds new permission", () => {
+ const result = PermissionNext.merge(
+ [{ permission: "bash", pattern: "*", action: "allow" }],
+ [{ permission: "edit", pattern: "*", action: "deny" }],
+ )
+ expect(result).toEqual([
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "edit", pattern: "*", action: "deny" },
+ ])
+})
+
+test("merge - concatenates rules for same permission", () => {
+ const result = PermissionNext.merge(
+ [{ permission: "bash", pattern: "foo", action: "ask" }],
+ [{ permission: "bash", pattern: "*", action: "deny" }],
+ )
+ expect(result).toEqual([
+ { permission: "bash", pattern: "foo", action: "ask" },
+ { permission: "bash", pattern: "*", action: "deny" },
+ ])
+})
+
+test("merge - multiple rulesets", () => {
+ const result = PermissionNext.merge(
+ [{ permission: "bash", pattern: "*", action: "allow" }],
+ [{ permission: "bash", pattern: "rm", action: "ask" }],
+ [{ permission: "edit", pattern: "*", action: "allow" }],
+ )
+ expect(result).toEqual([
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm", action: "ask" },
+ { permission: "edit", pattern: "*", action: "allow" },
+ ])
+})
+
+test("merge - empty ruleset does nothing", () => {
+ const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
+ expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
+})
+
+test("merge - preserves rule order", () => {
+ const result = PermissionNext.merge(
+ [
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ { permission: "edit", pattern: "src/secret/*", action: "deny" },
+ ],
+ [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
+ )
+ expect(result).toEqual([
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ { permission: "edit", pattern: "src/secret/*", action: "deny" },
+ { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
+ ])
+})
+
+test("merge - config permission overrides default ask", () => {
+ // Simulates: defaults have "*": "ask", config sets bash: "allow"
+ const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
+ const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+ const merged = PermissionNext.merge(defaults, config)
+
+ // Config's bash allow should override default ask
+ expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow")
+ // Other permissions should still be ask (from defaults)
+ expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask")
+})
+
+test("merge - config ask overrides default allow", () => {
+ // Simulates: defaults have bash: "allow", config sets bash: "ask"
+ const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+ const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
+ const merged = PermissionNext.merge(defaults, config)
+
+ // Config's ask should override default allow
+ expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask")
+})
+
+// evaluate tests
+
+test("evaluate - exact pattern match", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard pattern match", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - last matching rule wins", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm", action: "deny" },
+ ])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - last matching rule wins (wildcard after specific)", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [
+ { permission: "bash", pattern: "rm", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - glob pattern match", () => {
+ const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - last matching glob wins", () => {
+ const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+ { permission: "edit", pattern: "src/*", action: "deny" },
+ { permission: "edit", pattern: "src/components/*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - order matters for specificity", () => {
+ // If more specific rule comes first, later wildcard overrides it
+ const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+ { permission: "edit", pattern: "src/components/*", action: "allow" },
+ { permission: "edit", pattern: "src/*", action: "deny" },
+ ])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - unknown permission returns ask", () => {
+ const result = PermissionNext.evaluate("unknown_tool", "anything", [
+ { permission: "bash", pattern: "*", action: "allow" },
+ ])
+ expect(result).toBe("ask")
+})
+
+test("evaluate - empty ruleset returns ask", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [])
+ expect(result).toBe("ask")
+})
+
+test("evaluate - no matching pattern returns ask", () => {
+ const result = PermissionNext.evaluate("edit", "etc/passwd", [
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ ])
+ expect(result).toBe("ask")
+})
+
+test("evaluate - empty rules array returns ask", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [])
+ expect(result).toBe("ask")
+})
+
+test("evaluate - multiple matching patterns, last wins", () => {
+ const result = PermissionNext.evaluate("edit", "src/secret.ts", [
+ { permission: "edit", pattern: "*", action: "ask" },
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ { permission: "edit", pattern: "src/secret.ts", action: "deny" },
+ ])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - non-matching patterns are skipped", () => {
+ const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+ { permission: "edit", pattern: "*", action: "ask" },
+ { permission: "edit", pattern: "test/*", action: "deny" },
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - exact match at end wins over earlier wildcard", () => {
+ const result = PermissionNext.evaluate("bash", "/bin/rm", [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "/bin/rm", action: "deny" },
+ ])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard at end overrides earlier exact match", () => {
+ const result = PermissionNext.evaluate("bash", "/bin/rm", [
+ { permission: "bash", pattern: "/bin/rm", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+// wildcard permission tests
+
+test("evaluate - wildcard permission matches any permission", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard permission with specific pattern", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - glob permission pattern", () => {
+ const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
+ { permission: "mcp_*", pattern: "*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - specific permission and wildcard permission combined", () => {
+ const result = PermissionNext.evaluate("bash", "rm", [
+ { permission: "*", pattern: "*", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - wildcard permission does not match when specific exists", () => {
+ const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+ { permission: "*", pattern: "*", action: "deny" },
+ { permission: "edit", pattern: "src/*", action: "allow" },
+ ])
+ expect(result).toBe("allow")
+})
+
+test("evaluate - multiple matching permission patterns combine rules", () => {
+ const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
+ { permission: "*", pattern: "*", action: "ask" },
+ { permission: "mcp_*", pattern: "*", action: "allow" },
+ { permission: "mcp_dangerous", pattern: "*", action: "deny" },
+ ])
+ expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard permission fallback for unknown tool", () => {
+ const result = PermissionNext.evaluate("unknown_tool", "anything", [
+ { permission: "*", pattern: "*", action: "ask" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ])
+ expect(result).toBe("ask")
+})
+
+test("evaluate - permission patterns sorted by length regardless of object order", () => {
+ // specific permission listed before wildcard, but specific should still win
+ const result = PermissionNext.evaluate("bash", "rm", [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "*", pattern: "*", action: "deny" },
+ ])
+ // With flat list, last matching rule wins - so "*" matches bash and wins
+ expect(result).toBe("deny")
+})
+
+test("evaluate - merges multiple rulesets", () => {
+ const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+ const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
+ // approved comes after config, so rm should be denied
+ const result = PermissionNext.evaluate("bash", "rm", config, approved)
+ expect(result).toBe("deny")
+})
+
+// disabled tests
+
+test("disabled - returns empty set when all tools allowed", () => {
+ const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
+ expect(result.size).toBe(0)
+})
+
+test("disabled - disables tool when denied", () => {
+ const result = PermissionNext.disabled(
+ ["bash", "edit", "read"],
+ [
+ { permission: "*", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "*", action: "deny" },
+ ],
+ )
+ expect(result.has("bash")).toBe(true)
+ expect(result.has("edit")).toBe(false)
+ expect(result.has("read")).toBe(false)
+})
+
+test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
+ const result = PermissionNext.disabled(
+ ["edit", "write", "patch", "multiedit", "bash"],
+ [
+ { permission: "*", pattern: "*", action: "allow" },
+ { permission: "edit", pattern: "*", action: "deny" },
+ ],
+ )
+ expect(result.has("edit")).toBe(true)
+ expect(result.has("write")).toBe(true)
+ expect(result.has("patch")).toBe(true)
+ expect(result.has("multiedit")).toBe(true)
+ expect(result.has("bash")).toBe(false)
+})
+
+test("disabled - does not disable when partially denied", () => {
+ const result = PermissionNext.disabled(
+ ["bash"],
+ [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ ],
+ )
+ expect(result.has("bash")).toBe(false)
+})
+
+test("disabled - does not disable when action is ask", () => {
+ const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
+ expect(result.size).toBe(0)
+})
+
+test("disabled - disables when wildcard deny even with specific allow", () => {
+ // Tool is disabled because evaluate("bash", "*", ...) returns "deny"
+ // The "echo *" allow rule doesn't match the "*" pattern we're checking
+ const result = PermissionNext.disabled(
+ ["bash"],
+ [
+ { permission: "bash", pattern: "*", action: "deny" },
+ { permission: "bash", pattern: "echo *", action: "allow" },
+ ],
+ )
+ expect(result.has("bash")).toBe(true)
+})
+
+test("disabled - does not disable when wildcard allow after deny", () => {
+ const result = PermissionNext.disabled(
+ ["bash"],
+ [
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ],
+ )
+ expect(result.has("bash")).toBe(false)
+})
+
+test("disabled - disables multiple tools", () => {
+ const result = PermissionNext.disabled(
+ ["bash", "edit", "webfetch"],
+ [
+ { permission: "bash", pattern: "*", action: "deny" },
+ { permission: "edit", pattern: "*", action: "deny" },
+ { permission: "webfetch", pattern: "*", action: "deny" },
+ ],
+ )
+ expect(result.has("bash")).toBe(true)
+ expect(result.has("edit")).toBe(true)
+ expect(result.has("webfetch")).toBe(true)
+})
+
+test("disabled - wildcard permission denies all tools", () => {
+ const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
+ expect(result.has("bash")).toBe(true)
+ expect(result.has("edit")).toBe(true)
+ expect(result.has("read")).toBe(true)
+})
+
+test("disabled - specific allow overrides wildcard deny", () => {
+ const result = PermissionNext.disabled(
+ ["bash", "edit", "read"],
+ [
+ { permission: "*", pattern: "*", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ],
+ )
+ expect(result.has("bash")).toBe(false)
+ expect(result.has("edit")).toBe(true)
+ expect(result.has("read")).toBe(true)
+})
+
+// ask tests
+
+test("ask - resolves immediately when action is allow", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await PermissionNext.ask({
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
+ })
+ expect(result).toBeUndefined()
+ },
+ })
+})
+
+test("ask - throws RejectedError when action is deny", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(
+ PermissionNext.ask({
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["rm -rf /"],
+ metadata: {},
+ always: [],
+ ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
+ }),
+ ).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+ },
+ })
+})
+
+test("ask - returns pending promise when action is ask", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const promise = PermissionNext.ask({
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
+ })
+ // Promise should be pending, not resolved
+ expect(promise).toBeInstanceOf(Promise)
+ // Don't await - just verify it returns a promise
+ },
+ })
+})
+
+// reply tests
+
+test("reply - once resolves the pending ask", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const askPromise = PermissionNext.ask({
+ id: "permission_test1",
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ await PermissionNext.reply({
+ requestID: "permission_test1",
+ reply: "once",
+ })
+
+ await expect(askPromise).resolves.toBeUndefined()
+ },
+ })
+})
+
+test("reply - reject throws RejectedError", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const askPromise = PermissionNext.ask({
+ id: "permission_test2",
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ await PermissionNext.reply({
+ requestID: "permission_test2",
+ reply: "reject",
+ })
+
+ await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+ },
+ })
+})
+
+test("reply - always persists approval and resolves", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const askPromise = PermissionNext.ask({
+ id: "permission_test3",
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: ["ls"],
+ ruleset: [],
+ })
+
+ await PermissionNext.reply({
+ requestID: "permission_test3",
+ reply: "always",
+ })
+
+ await expect(askPromise).resolves.toBeUndefined()
+ },
+ })
+ // Re-provide to reload state with stored permissions
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Stored approval should allow without asking
+ const result = await PermissionNext.ask({
+ sessionID: "session_test2",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+ expect(result).toBeUndefined()
+ },
+ })
+})
+
+test("reply - reject cancels all pending for same session", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const askPromise1 = PermissionNext.ask({
+ id: "permission_test4a",
+ sessionID: "session_same",
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ const askPromise2 = PermissionNext.ask({
+ id: "permission_test4b",
+ sessionID: "session_same",
+ permission: "edit",
+ patterns: ["foo.ts"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ // Catch rejections before they become unhandled
+ const result1 = askPromise1.catch((e) => e)
+ const result2 = askPromise2.catch((e) => e)
+
+ // Reject the first one
+ await PermissionNext.reply({
+ requestID: "permission_test4a",
+ reply: "reject",
+ })
+
+ // Both should be rejected
+ expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
+ expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
+ },
+ })
+})
+
+test("ask - checks all patterns and stops on first deny", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(
+ PermissionNext.ask({
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["echo hello", "rm -rf /"],
+ metadata: {},
+ always: [],
+ ruleset: [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ ],
+ }),
+ ).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+ },
+ })
+})
+
+test("ask - allows all patterns when all match allow rules", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await PermissionNext.ask({
+ sessionID: "session_test",
+ permission: "bash",
+ patterns: ["echo hello", "ls -la", "pwd"],
+ metadata: {},
+ always: [],
+ ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
+ })
+ expect(result).toBeUndefined()
+ },
+ })
+})
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index 9ef7dfb9d..ee82813fb 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
-import { Permission } from "../../src/permission"
import { tmpdir } from "../fixture/fixture"
+import type { PermissionNext } from "../../src/permission/next"
const ctx = {
sessionID: "test",
@@ -12,6 +12,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
+ ask: async () => {},
}
const projectRoot = path.join(__dirname, "../..")
@@ -37,397 +38,164 @@ describe("tool.bash", () => {
})
describe("tool.bash permissions", () => {
- test("allows command matching allow pattern", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "echo *": "allow",
- "*": "deny",
- },
- },
- }),
- )
- },
- })
+ test("asks for bash permission with correct pattern", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const result = await bash.execute(
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ await bash.execute(
{
command: "echo hello",
description: "Echo hello",
},
- ctx,
+ testCtx,
)
- expect(result.metadata.exit).toBe(0)
- expect(result.metadata.output).toContain("hello")
+ expect(requests.length).toBe(1)
+ expect(requests[0].permission).toBe("bash")
+ expect(requests[0].patterns).toContain("echo hello")
},
})
})
- test("denies command matching deny pattern", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "curl *": "deny",
- "*": "allow",
- },
- },
- }),
- )
- },
- })
+ test("asks for bash permission with multiple commands", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- await expect(
- bash.execute(
- {
- command: "curl https://example.com",
- description: "Fetch URL",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
- },
- })
- })
-
- test("denies all commands with wildcard deny", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "*": "deny",
- },
- },
- }),
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ await bash.execute(
+ {
+ command: "echo foo && echo bar",
+ description: "Echo twice",
+ },
+ testCtx,
)
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const bash = await BashTool.init()
- await expect(
- bash.execute(
- {
- command: "ls",
- description: "List files",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
+ expect(requests.length).toBe(1)
+ expect(requests[0].permission).toBe("bash")
+ expect(requests[0].patterns).toContain("echo foo")
+ expect(requests[0].patterns).toContain("echo bar")
},
})
})
- test("more specific pattern overrides general pattern", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "*": "deny",
- "ls *": "allow",
- "pwd*": "allow",
- },
- },
- }),
- )
- },
- })
+ test("asks for external_directory permission when cd to parent", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- // ls should be allowed
- const result = await bash.execute(
- {
- command: "ls -la",
- description: "List files",
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
},
- ctx,
- )
- expect(result.metadata.exit).toBe(0)
-
- // pwd should be allowed
- const pwd = await bash.execute(
+ }
+ await bash.execute(
{
- command: "pwd",
- description: "Print working directory",
+ command: "cd ../",
+ description: "Change to parent directory",
},
- ctx,
+ testCtx,
)
- expect(pwd.metadata.exit).toBe(0)
-
- // cat should be denied
- await expect(
- bash.execute(
- {
- command: "cat /etc/passwd",
- description: "Read file",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
+ const extDirReq = requests.find((r) => r.permission === "external_directory")
+ expect(extDirReq).toBeDefined()
},
})
})
- test("denies dangerous subcommands while allowing safe ones", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "find *": "allow",
- "find * -delete*": "deny",
- "find * -exec*": "deny",
- "*": "deny",
- },
- },
- }),
- )
- },
- })
+ test("asks for external_directory permission when workdir is outside project", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- // Basic find should work
- const result = await bash.execute(
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ await bash.execute(
{
- command: "find . -name '*.ts'",
- description: "Find typescript files",
+ command: "ls",
+ workdir: "/tmp",
+ description: "List /tmp",
},
- ctx,
+ testCtx,
)
- expect(result.metadata.exit).toBe(0)
-
- // find -delete should be denied
- await expect(
- bash.execute(
- {
- command: "find . -name '*.tmp' -delete",
- description: "Delete temp files",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
-
- // find -exec should be denied
- await expect(
- bash.execute(
- {
- command: "find . -name '*.ts' -exec cat {} \\;",
- description: "Find and cat files",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
+ const extDirReq = requests.find((r) => r.permission === "external_directory")
+ expect(extDirReq).toBeDefined()
+ expect(extDirReq!.patterns).toContain("/tmp")
},
})
})
- test("allows git read commands while denying writes", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "git status*": "allow",
- "git log*": "allow",
- "git diff*": "allow",
- "git branch": "allow",
- "git commit *": "deny",
- "git push *": "deny",
- "*": "deny",
- },
- },
- }),
- )
- },
- })
+ test("includes always patterns for auto-approval", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- // git status should work
- const status = await bash.execute(
- {
- command: "git status",
- description: "Git status",
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
},
- ctx,
- )
- expect(status.metadata.exit).toBe(0)
-
- // git log should work
- const log = await bash.execute(
+ }
+ await bash.execute(
{
command: "git log --oneline -5",
description: "Git log",
},
- ctx,
+ testCtx,
)
- expect(log.metadata.exit).toBe(0)
-
- // git commit should be denied
- await expect(
- bash.execute(
- {
- command: "git commit -m 'test'",
- description: "Git commit",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
-
- // git push should be denied
- await expect(
- bash.execute(
- {
- command: "git push origin main",
- description: "Git push",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
+ expect(requests.length).toBe(1)
+ expect(requests[0].always.length).toBeGreaterThan(0)
+ expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true)
},
})
})
- test("denies external directory access when permission is deny", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "deny",
- bash: {
- "*": "allow",
- },
- },
- }),
- )
- },
- })
+ test("does not ask for bash permission when command is cd only", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- // Should deny cd to parent directory (cd is checked for external paths)
- await expect(
- bash.execute(
- {
- command: "cd ../",
- description: "Change to parent directory",
- },
- ctx,
- ),
- ).rejects.toThrow()
- },
- })
- })
-
- test("denies workdir outside project when external_directory is deny", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "deny",
- bash: {
- "*": "allow",
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const bash = await BashTool.init()
- await expect(
- bash.execute(
- {
- command: "ls",
- workdir: "/tmp",
- description: "List /tmp",
- },
- ctx,
- ),
- ).rejects.toThrow()
- },
- })
- })
-
- test("handles multiple commands in sequence", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- bash: {
- "echo *": "allow",
- "curl *": "deny",
- "*": "deny",
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const bash = await BashTool.init()
- // echo && echo should work
- const result = await bash.execute(
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ await bash.execute(
{
- command: "echo foo && echo bar",
- description: "Echo twice",
+ command: "cd .",
+ description: "Stay in current directory",
},
- ctx,
+ testCtx,
)
- expect(result.metadata.output).toContain("foo")
- expect(result.metadata.output).toContain("bar")
-
- // echo && curl should fail (curl is denied)
- await expect(
- bash.execute(
- {
- command: "echo hi && curl https://example.com",
- description: "Echo then curl",
- },
- ctx,
- ),
- ).rejects.toThrow("restricted")
+ const bashReq = requests.find((r) => r.permission === "bash")
+ expect(bashReq).toBeUndefined()
},
})
})
diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts
index f3da666a0..a79d93157 100644
--- a/packages/opencode/test/tool/grep.test.ts
+++ b/packages/opencode/test/tool/grep.test.ts
@@ -11,6 +11,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
+ ask: async () => {},
}
const projectRoot = path.join(__dirname, "../..")
diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts
index 6d7d6db87..3d3ec574e 100644
--- a/packages/opencode/test/tool/patch.test.ts
+++ b/packages/opencode/test/tool/patch.test.ts
@@ -3,16 +3,17 @@ import path from "path"
import { PatchTool } from "../../src/tool/patch"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
-import { Permission } from "../../src/permission"
+import { PermissionNext } from "../../src/permission/next"
import * as fs from "fs/promises"
const ctx = {
sessionID: "test",
messageID: "",
- toolCallID: "",
+ callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
+ ask: async () => {},
}
const patchTool = await PatchTool.init()
@@ -59,7 +60,8 @@ describe("tool.patch", () => {
patchTool.execute({ patchText: maliciousPatch }, ctx)
// TODO: this sucks
await new Promise((resolve) => setTimeout(resolve, 1000))
- expect(Permission.pending()[ctx.sessionID]).toBeDefined()
+ const pending = await PermissionNext.list()
+ expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined()
},
})
})
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index eb860d04f..1093a17fe 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -3,6 +3,7 @@ import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
+import type { PermissionNext } from "../../src/permission/next"
const ctx = {
sessionID: "test",
@@ -11,6 +12,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
+ ask: async () => {},
}
describe("tool.read external_directory permission", () => {
@@ -18,14 +20,6 @@ describe("tool.read external_directory permission", () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "deny",
- },
- }),
- )
},
})
await Instance.provide({
@@ -42,14 +36,6 @@ describe("tool.read external_directory permission", () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "deny",
- },
- }),
- )
},
})
await Instance.provide({
@@ -62,83 +48,74 @@ describe("tool.read external_directory permission", () => {
})
})
- test("denies reading absolute path outside project directory", async () => {
+ test("asks for external_directory permission when reading absolute path outside project", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "secret.txt"), "secret data")
},
})
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "deny",
- },
- }),
- )
- },
- })
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow(
- "not in the current working directory",
- )
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
+ const extDirReq = requests.find((r) => r.permission === "external_directory")
+ expect(extDirReq).toBeDefined()
+ expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true)
},
})
})
- test("denies reading relative path that traverses outside project directory", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "deny",
- },
- }),
- )
- },
- })
+ test("asks for external_directory permission when reading relative path outside project", async () => {
+ await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow(
- "not in the current working directory",
- )
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ // This will fail because file doesn't exist, but we can check if permission was asked
+ await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
+ const extDirReq = requests.find((r) => r.permission === "external_directory")
+ expect(extDirReq).toBeDefined()
},
})
})
- test("allows reading outside project directory when external_directory is allow", async () => {
- await using outerTmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(path.join(dir, "external.txt"), "external content")
- },
- })
+ test("does not ask for external_directory permission when reading inside project", async () => {
await using tmp = await tmpdir({
+ git: true,
init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- permission: {
- external_directory: "allow",
- },
- }),
- )
+ await Bun.write(path.join(dir, "internal.txt"), "internal content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx)
- expect(result.output).toContain("external content")
+ const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const testCtx = {
+ ...ctx,
+ ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ requests.push(req)
+ },
+ }
+ await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
+ const extDirReq = requests.find((r) => r.permission === "external_directory")
+ expect(extDirReq).toBeUndefined()
},
})
})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index b0610b64b..f56e83677 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -55,8 +55,11 @@ import type {
PartUpdateResponses,
PathGetResponses,
PermissionListResponses,
+ PermissionReplyErrors,
+ PermissionReplyResponses,
PermissionRespondErrors,
PermissionRespondResponses,
+ PermissionRuleset,
ProjectCurrentResponses,
ProjectListResponses,
ProjectUpdateErrors,
@@ -728,6 +731,7 @@ export class Session extends HeyApiClient {
directory?: string
parentID?: string
title?: string
+ permission?: PermissionRuleset
},
options?: Options<never, ThrowOnError>,
) {
@@ -739,6 +743,7 @@ export class Session extends HeyApiClient {
{ in: "query", key: "directory" },
{ in: "body", key: "parentID" },
{ in: "body", key: "title" },
+ { in: "body", key: "permission" },
],
},
],
@@ -1591,6 +1596,8 @@ export class Permission extends HeyApiClient {
* Respond to permission
*
* Approve or deny a permission request from the AI assistant.
+ *
+ * @deprecated
*/
public respond<ThrowOnError extends boolean = false>(
parameters: {
@@ -1627,6 +1634,43 @@ export class Permission extends HeyApiClient {
}
/**
+ * Respond to permission request
+ *
+ * Approve or deny a permission request from the AI assistant.
+ */
+ public reply<ThrowOnError extends boolean = false>(
+ parameters: {
+ requestID: string
+ directory?: string
+ reply?: "once" | "always" | "reject"
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "requestID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "reply" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<PermissionReplyResponses, PermissionReplyErrors, ThrowOnError>({
+ url: "/permission/{requestID}/reply",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
* List pending permissions
*
* Get all pending permission requests across all sessions.
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 85a3c4286..10764bebe 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -451,67 +451,32 @@ export type EventMessagePartRemoved = {
}
}
-export type Permission = {
+export type PermissionRequest = {
id: string
- type: string
- pattern?: string | Array<string>
sessionID: string
- messageID: string
- callID?: string
- title: string
+ permission: string
+ patterns: Array<string>
metadata: {
[key: string]: unknown
}
- time: {
- created: number
+ always: Array<string>
+ tool?: {
+ messageID: string
+ callID: string
}
}
-export type EventPermissionUpdated = {
- type: "permission.updated"
- properties: Permission
+export type EventPermissionAsked = {
+ type: "permission.asked"
+ properties: PermissionRequest
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
- permissionID: string
- response: string
- }
-}
-
-export type EventFileEdited = {
- type: "file.edited"
- properties: {
- file: string
- }
-}
-
-export type Todo = {
- /**
- * Brief description of the task
- */
- content: string
- /**
- * Current status of the task: pending, in_progress, completed, cancelled
- */
- status: string
- /**
- * Priority level of the task: high, medium, low
- */
- priority: string
- /**
- * Unique identifier for the todo item
- */
- id: string
-}
-
-export type EventTodoUpdated = {
- type: "todo.updated"
- properties: {
- sessionID: string
- todos: Array<Todo>
+ requestID: string
+ reply: "once" | "always" | "reject"
}
}
@@ -551,6 +516,40 @@ export type EventSessionCompacted = {
}
}
+export type EventFileEdited = {
+ type: "file.edited"
+ properties: {
+ file: string
+ }
+}
+
+export type Todo = {
+ /**
+ * Brief description of the task
+ */
+ content: string
+ /**
+ * Current status of the task: pending, in_progress, completed, cancelled
+ */
+ status: string
+ /**
+ * Priority level of the task: high, medium, low
+ */
+ priority: string
+ /**
+ * Unique identifier for the todo item
+ */
+ id: string
+}
+
+export type EventTodoUpdated = {
+ type: "todo.updated"
+ properties: {
+ sessionID: string
+ todos: Array<Todo>
+ }
+}
+
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -610,6 +609,16 @@ export type EventCommandExecuted = {
}
}
+export type PermissionAction = "allow" | "deny" | "ask"
+
+export type PermissionRule = {
+ permission: string
+ pattern: string
+ action: PermissionAction
+}
+
+export type PermissionRuleset = Array<PermissionRule>
+
export type Session = {
id: string
projectID: string
@@ -632,6 +641,7 @@ export type Session = {
compacting?: number
archived?: number
}
+ permission?: PermissionRuleset
revert?: {
messageID: string
partID?: string
@@ -756,13 +766,13 @@ export type Event =
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
- | EventPermissionUpdated
+ | EventPermissionAsked
| EventPermissionReplied
- | EventFileEdited
- | EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
+ | EventFileEdited
+ | EventTodoUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -1183,11 +1193,43 @@ export type ServerConfig = {
cors?: Array<string>
}
+export type PermissionActionConfig = "ask" | "allow" | "deny"
+
+export type PermissionObjectConfig = {
+ [key: string]: PermissionActionConfig
+}
+
+export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig
+
+export type PermissionConfig =
+ | {
+ read?: PermissionRuleConfig
+ edit?: PermissionRuleConfig
+ glob?: PermissionRuleConfig
+ grep?: PermissionRuleConfig
+ list?: PermissionRuleConfig
+ bash?: PermissionRuleConfig
+ task?: PermissionRuleConfig
+ external_directory?: PermissionRuleConfig
+ todowrite?: PermissionActionConfig
+ todoread?: PermissionActionConfig
+ webfetch?: PermissionActionConfig
+ websearch?: PermissionActionConfig
+ codesearch?: PermissionActionConfig
+ lsp?: PermissionRuleConfig
+ doom_loop?: PermissionActionConfig
+ [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
+ }
+ | PermissionActionConfig
+
export type AgentConfig = {
model?: string
temperature?: number
top_p?: number
prompt?: string
+ /**
+ * @deprecated Use 'permission' field instead
+ */
tools?: {
[key: string]: boolean
}
@@ -1197,6 +1239,9 @@ export type AgentConfig = {
*/
description?: string
mode?: "subagent" | "primary" | "all"
+ options?: {
+ [key: string]: unknown
+ }
/**
* Hex color code for the agent (e.g., #FF5733)
*/
@@ -1204,27 +1249,12 @@ export type AgentConfig = {
/**
* Maximum number of agentic iterations before forcing text-only response
*/
+ steps?: number
+ /**
+ * @deprecated Use 'steps' field instead.
+ */
maxSteps?: number
- permission?: {
- edit?: "ask" | "allow" | "deny"
- bash?:
- | "ask"
- | "allow"
- | "deny"
- | {
- [key: string]: "ask" | "allow" | "deny"
- }
- skill?:
- | "ask"
- | "allow"
- | "deny"
- | {
- [key: string]: "ask" | "allow" | "deny"
- }
- webfetch?: "ask" | "allow" | "deny"
- doom_loop?: "ask" | "allow" | "deny"
- external_directory?: "ask" | "allow" | "deny"
- }
+ permission?: PermissionConfig
[key: string]:
| unknown
| string
@@ -1236,28 +1266,12 @@ export type AgentConfig = {
| "subagent"
| "primary"
| "all"
- | string
- | number
| {
- edit?: "ask" | "allow" | "deny"
- bash?:
- | "ask"
- | "allow"
- | "deny"
- | {
- [key: string]: "ask" | "allow" | "deny"
- }
- skill?:
- | "ask"
- | "allow"
- | "deny"
- | {
- [key: string]: "ask" | "allow" | "deny"
- }
- webfetch?: "ask" | "allow" | "deny"
- doom_loop?: "ask" | "allow" | "deny"
- external_directory?: "ask" | "allow" | "deny"
+ [key: string]: unknown
}
+ | string
+ | number
+ | PermissionConfig
| undefined
}
@@ -1578,26 +1592,7 @@ export type Config = {
*/
instructions?: Array<string>
layout?: LayoutConfig
- permission?: {
- edit?: "ask" | "allow" | "deny"
- bash?:
- | "ask"
- | "allow"
- | "deny"
- | {
- [key: string]: "ask" | "allow" | "deny"
- }
- skill?:
- | "ask"
- | "allow"
- | "deny"
- | {
- [key: string]: "ask" | "allow" | "deny"
- }
- webfetch?: "ask" | "allow" | "deny"
- doom_loop?: "ask" | "allow" | "deny"
- external_directory?: "ask" | "allow" | "deny"
- }
+ permission?: PermissionConfig
tools?: {
[key: string]: boolean
}
@@ -1886,34 +1881,19 @@ export type Agent = {
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
- default?: boolean
topP?: number
temperature?: number
color?: string
- permission: {
- edit: "ask" | "allow" | "deny"
- bash: {
- [key: string]: "ask" | "allow" | "deny"
- }
- skill: {
- [key: string]: "ask" | "allow" | "deny"
- }
- webfetch?: "ask" | "allow" | "deny"
- doom_loop?: "ask" | "allow" | "deny"
- external_directory?: "ask" | "allow" | "deny"
- }
+ permission: PermissionRuleset
model?: {
modelID: string
providerID: string
}
prompt?: string
- tools: {
- [key: string]: boolean
- }
options: {
[key: string]: unknown
}
- maxSteps?: number
+ steps?: number
}
export type McpStatusConnected = {
@@ -2457,6 +2437,7 @@ export type SessionCreateData = {
body?: {
parentID?: string
title?: string
+ permission?: PermissionRuleset
}
path?: never
query?: {
@@ -2972,6 +2953,9 @@ export type SessionPromptData = {
}
agent?: string
noReply?: boolean
+ /**
+ * @deprecated tools and permissions have been merged, you can set permissions on the session itself now
+ */
tools?: {
[key: string]: boolean
}
@@ -3156,6 +3140,9 @@ export type SessionPromptAsyncData = {
}
agent?: string
noReply?: boolean
+ /**
+ * @deprecated tools and permissions have been merged, you can set permissions on the session itself now
+ */
tools?: {
[key: string]: boolean
}
@@ -3391,6 +3378,41 @@ export type PermissionRespondResponses = {
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
+export type PermissionReplyData = {
+ body?: {
+ reply: "once" | "always" | "reject"
+ }
+ path: {
+ requestID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/permission/{requestID}/reply"
+}
+
+export type PermissionReplyErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors]
+
+export type PermissionReplyResponses = {
+ /**
+ * Permission processed successfully
+ */
+ 200: boolean
+}
+
+export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses]
+
export type PermissionListData = {
body?: never
path?: never
@@ -3404,7 +3426,7 @@ export type PermissionListResponses = {
/**
* List of pending permissions
*/
- 200: Array<Permission>
+ 200: Array<PermissionRequest>
}
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 973c217fd..efa90d0b7 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
{
"openapi": "3.1.1",
"info": {
@@ -9750,3 +9751,6 @@
}
}
}
+=======
+{}
+>>>>>>> 4f732c838 (feat: add command-aware permission request system for granular tool approval)
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index ac80dada7..d0e8afefd 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -455,8 +455,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const permission = createMemo(() => {
const next = data.store.permission?.[props.message.sessionID]?.[0]
- if (!next) return undefined
- if (next.callID !== part.callID) return undefined
+ if (!next || !next.tool) return undefined
+ if (next.tool!.callID !== part.callID) return undefined
return next
})
@@ -732,19 +732,20 @@ ToolRegistry.register({
const childToolPart = createMemo(() => {
const perm = childPermission()
- if (!perm) return undefined
+ if (!perm || !perm.tool) return undefined
const sessionId = childSessionId()
if (!sessionId) return undefined
// Find the tool part that matches the permission's callID
const messages = data.store.message[sessionId] ?? []
- for (const msg of messages) {
- const parts = data.store.part[msg.id] ?? []
- for (const part of parts) {
- if (part.type === "tool" && (part as ToolPart).callID === perm.callID) {
- return { part: part as ToolPart, message: msg }
- }
+ const message = messages.findLast((m) => m.id === perm.tool!.messageID)
+ if (!message) return undefined
+ const parts = data.store.part[message.id] ?? []
+ for (const part of parts) {
+ if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) {
+ return { part: part as ToolPart, message }
}
}
+
return undefined
})
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 0ef1f135c..8285b9822 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -2,7 +2,7 @@ import {
AssistantMessage,
Message as MessageType,
Part as PartType,
- type Permission,
+ type PermissionRequest,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2/client"
@@ -132,7 +132,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
- const emptyPermissions: Permission[] = []
+ const emptyPermissions: PermissionRequest[] = []
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
@@ -235,16 +235,18 @@ export function SessionTurn(
if (props.stepsExpanded) return emptyPermissionParts
const next = nextPermission()
- if (!next) return emptyPermissionParts
-
- for (const message of assistantMessages()) {
- const parts = data.store.part[message.id] ?? emptyParts
- for (const part of parts) {
- if (part?.type !== "tool") continue
- const tool = part as ToolPart
- if (tool.callID === next.callID) return [{ part: tool, message }]
- }
+ if (!next || !next.tool) return emptyPermissionParts
+
+ const message = assistantMessages().findLast((m) => m.id === next.tool!.messageID)
+ if (!message) return emptyPermissionParts
+
+ const parts = data.store.part[message.id] ?? emptyParts
+ for (const part of parts) {
+ if (part?.type !== "tool") continue
+ const tool = part as ToolPart
+ if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
}
+
return emptyPermissionParts
})
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index 3292ba579..9f7ec813f 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -1,4 +1,4 @@
-import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2"
+import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -14,7 +14,7 @@ type Data = {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
permission?: {
- [sessionID: string]: Permission[]
+ [sessionID: string]: PermissionRequest[]
}
message: {
[sessionID: string]: Message[]