فهرست منبع

Permission rework (#6319)

Co-authored-by: Github Action <[email protected]>
Co-authored-by: Adam <[email protected]>
Dax 1 ماه پیش
والد
کامیت
351ddeed91
66فایلهای تغییر یافته به همراه3532 افزوده شده و 2023 حذف شده
  1. 2 4
      .github/workflows/test.yml
  2. 0 10
      .opencode/agent/git-committer.md
  3. 7 1
      .opencode/opencode.jsonc
  4. 4 0
      bunfig.toml
  5. 3 3
      flake.lock
  6. 2 1
      package.json
  7. 5 5
      packages/app/src/context/global-sync.tsx
  8. 6 8
      packages/app/src/context/permission.tsx
  9. 1 1
      packages/app/src/pages/layout.tsx
  10. 14 17
      packages/opencode/src/acp/agent.ts
  11. 85 237
      packages/opencode/src/agent/agent.ts
  12. 2 1
      packages/opencode/src/cli/cmd/agent.ts
  13. 1 24
      packages/opencode/src/cli/cmd/debug/agent.ts
  14. 3 3
      packages/opencode/src/cli/cmd/run.ts
  15. 0 1
      packages/opencode/src/cli/cmd/tui/app.tsx
  16. 4 2
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  17. 1 1
      packages/opencode/src/cli/cmd/tui/context/local.tsx
  18. 22 20
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  19. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
  20. 385 449
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  21. 313 0
      packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
  22. 1 0
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  23. 103 28
      packages/opencode/src/config/config.ts
  24. 1 0
      packages/opencode/src/installation/index.ts
  25. 163 0
      packages/opencode/src/permission/arity.ts
  26. 3 3
      packages/opencode/src/permission/index.ts
  27. 253 0
      packages/opencode/src/permission/next.ts
  28. 1 0
      packages/opencode/src/plugin/index.ts
  29. 43 9
      packages/opencode/src/server/server.ts
  30. 12 1
      packages/opencode/src/session/index.ts
  31. 6 8
      packages/opencode/src/session/llm.ts
  32. 14 29
      packages/opencode/src/session/processor.ts
  33. 131 82
      packages/opencode/src/session/prompt.ts
  34. 1 1
      packages/opencode/src/session/system.ts
  35. 27 73
      packages/opencode/src/tool/bash.ts
  36. 9 15
      packages/opencode/src/tool/codesearch.ts
  37. 47 67
      packages/opencode/src/tool/edit.ts
  38. 11 1
      packages/opencode/src/tool/glob.ts
  39. 12 1
      packages/opencode/src/tool/grep.ts
  40. 10 1
      packages/opencode/src/tool/ls.ts
  41. 8 1
      packages/opencode/src/tool/lsp.ts
  42. 17 40
      packages/opencode/src/tool/patch.ts
  43. 16 28
      packages/opencode/src/tool/read.ts
  44. 0 24
      packages/opencode/src/tool/registry.ts
  45. 56 84
      packages/opencode/src/tool/skill.ts
  46. 33 2
      packages/opencode/src/tool/task.ts
  47. 18 4
      packages/opencode/src/tool/todo.ts
  48. 2 0
      packages/opencode/src/tool/tool.ts
  49. 10 16
      packages/opencode/src/tool/webfetch.ts
  50. 12 18
      packages/opencode/src/tool/websearch.ts
  51. 16 42
      packages/opencode/src/tool/write.ts
  52. 385 83
      packages/opencode/test/agent/agent.test.ts
  53. 190 30
      packages/opencode/test/config/config.test.ts
  54. 11 0
      packages/opencode/test/fixture/fixture.ts
  55. 33 0
      packages/opencode/test/permission/arity.test.ts
  56. 652 0
      packages/opencode/test/permission/next.test.ts
  57. 94 326
      packages/opencode/test/tool/bash.test.ts
  58. 1 0
      packages/opencode/test/tool/grep.test.ts
  59. 5 3
      packages/opencode/test/tool/patch.test.ts
  60. 41 64
      packages/opencode/test/tool/read.test.ts
  61. 44 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  62. 150 128
      packages/sdk/js/src/v2/gen/types.gen.ts
  63. 4 0
      packages/sdk/openapi.json
  64. 10 9
      packages/ui/src/components/message-part.tsx
  65. 13 11
      packages/ui/src/components/session-turn.tsx
  66. 2 2
      packages/ui/src/context/data.tsx

+ 2 - 4
.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:

+ 0 - 10
.opencode/agent/git-committer.md

@@ -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.

+ 7 - 1
.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,
   },

+ 4 - 0
bunfig.toml

@@ -1,2 +1,6 @@
 [install]
 exact = true
+
+[test]
+root = "./do-not-run-tests-from-root"
+

+ 3 - 3
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": {

+ 2 - 1
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": [

+ 5 - 5
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",

+ 6 - 8
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,

+ 1 - 1
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

+ 14 - 17
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) {

+ 85 - 237
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
-}

+ 2 - 1
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)
         }
       },
     })

+ 1 - 24
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
-}

+ 3 - 3
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",

+ 0 - 1
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"

+ 4 - 2
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()}

+ 1 - 1
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(() => [

+ 22 - 20
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

+ 1 - 1
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>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 385 - 449
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx


+ 313 - 0
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>
+  )
+}

+ 1 - 0
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()

+ 103 - 28
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({

+ 1 - 0
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"

+ 163 - 0
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
+  }
+}

+ 3 - 3
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(),

+ 253 - 0
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))
+  }
+}

+ 1 - 0
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) => {

+ 43 - 9
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)
         },
       )

+ 12 - 1
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(),

+ 6 - 8
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
   }

+ 14 - 29
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]

+ 131 - 82
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"),

+ 1 - 1
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,

+ 27 - 73
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: {},
         })
       }
 

+ 9 - 15
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",

+ 47 - 67
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,

+ 11 - 1
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)
 

+ 12 - 1
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()

+ 10 - 1
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 })) {

+ 8 - 1
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 = {

+ 17 - 40
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[] = []

+ 16 - 28
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"]

+ 0 - 24
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
-  }
 }

+ 56 - 84
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,
+        },
+      }
+    },
+  }
+})

+ 33 - 2
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,
       })

+ 18 - 4
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: {

+ 2 - 0
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

+ 10 - 16
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)
 

+ 12 - 18
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",

+ 16 - 42
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, {

+ 385 - 83
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")
     },
   })
 })

+ 190 - 30
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",
+      })
     },
   })
 })

+ 11 - 0
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 = {

+ 33 - 0
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"])
+})

+ 652 - 0
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()
+    },
+  })
+})

+ 94 - 326
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()
       },
     })
   })

+ 1 - 0
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, "../..")

+ 5 - 3
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()
       },
     })
   })

+ 41 - 64
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()
       },
     })
   })

+ 44 - 0
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: {
@@ -1626,6 +1633,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
    *

+ 150 - 128
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]

+ 4 - 0
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)

+ 10 - 9
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
     })
 

+ 13 - 11
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
   })
 

+ 2 - 2
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[]

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است