From 351ddeed914d237138fc6f3f8b3d65d2e559357a Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 1 Jan 2026 17:54:11 -0500 Subject: [PATCH] Permission rework (#6319) Co-authored-by: Github Action Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> --- .github/workflows/test.yml | 6 +- .opencode/agent/git-committer.md | 10 - .opencode/opencode.jsonc | 8 +- bunfig.toml | 4 + flake.lock | 6 +- package.json | 3 +- packages/app/src/context/global-sync.tsx | 10 +- packages/app/src/context/permission.tsx | 14 +- packages/app/src/pages/layout.tsx | 2 +- packages/opencode/src/acp/agent.ts | 31 +- packages/opencode/src/agent/agent.ts | 322 ++---- packages/opencode/src/cli/cmd/agent.ts | 3 +- packages/opencode/src/cli/cmd/debug/agent.ts | 25 +- packages/opencode/src/cli/cmd/run.ts | 6 +- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - .../cli/cmd/tui/component/prompt/index.tsx | 6 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 52 +- .../src/cli/cmd/tui/routes/session/footer.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 919 ++++++++---------- .../cli/cmd/tui/routes/session/permission.tsx | 313 ++++++ .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 1 + packages/opencode/src/config/config.ts | 131 ++- packages/opencode/src/installation/index.ts | 1 + packages/opencode/src/permission/arity.ts | 163 ++++ packages/opencode/src/permission/index.ts | 6 +- packages/opencode/src/permission/next.ts | 253 +++++ packages/opencode/src/plugin/index.ts | 1 + packages/opencode/src/server/server.ts | 52 +- packages/opencode/src/session/index.ts | 13 +- packages/opencode/src/session/llm.ts | 14 +- packages/opencode/src/session/processor.ts | 43 +- packages/opencode/src/session/prompt.ts | 213 ++-- packages/opencode/src/session/system.ts | 2 +- packages/opencode/src/tool/bash.ts | 100 +- packages/opencode/src/tool/codesearch.ts | 24 +- packages/opencode/src/tool/edit.ts | 114 +-- packages/opencode/src/tool/glob.ts | 12 +- packages/opencode/src/tool/grep.ts | 13 +- packages/opencode/src/tool/ls.ts | 11 +- packages/opencode/src/tool/lsp.ts | 9 +- packages/opencode/src/tool/patch.ts | 57 +- packages/opencode/src/tool/read.ts | 44 +- packages/opencode/src/tool/registry.ts | 24 - packages/opencode/src/tool/skill.ts | 140 ++- packages/opencode/src/tool/task.ts | 35 +- packages/opencode/src/tool/todo.ts | 22 +- packages/opencode/src/tool/tool.ts | 2 + packages/opencode/src/tool/webfetch.ts | 26 +- packages/opencode/src/tool/websearch.ts | 30 +- packages/opencode/src/tool/write.ts | 58 +- packages/opencode/test/agent/agent.test.ts | 506 ++++++++-- packages/opencode/test/config/config.test.ts | 254 ++++- packages/opencode/test/fixture/fixture.ts | 11 + .../opencode/test/permission/arity.test.ts | 33 + .../opencode/test/permission/next.test.ts | 652 +++++++++++++ packages/opencode/test/tool/bash.test.ts | 504 +++------- packages/opencode/test/tool/grep.test.ts | 1 + packages/opencode/test/tool/patch.test.ts | 8 +- packages/opencode/test/tool/read.test.ts | 105 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 44 + packages/sdk/js/src/v2/gen/types.gen.ts | 278 +++--- packages/sdk/openapi.json | 4 + packages/ui/src/components/message-part.tsx | 19 +- packages/ui/src/components/session-turn.tsx | 22 +- packages/ui/src/context/data.tsx | 4 +- 66 files changed, 3658 insertions(+), 2146 deletions(-) delete mode 100644 .opencode/agent/git-committer.md create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx create mode 100644 packages/opencode/src/permission/arity.ts create mode 100644 packages/opencode/src/permission/next.ts create mode 100644 packages/opencode/test/permission/arity.test.ts create mode 100644 packages/opencode/test/permission/next.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac1a24fd51..c39710bee8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,11 +2,9 @@ name: test on: push: - branches-ignore: - - production + branches: + - dev pull_request: - branches-ignore: - - production workflow_dispatch: jobs: test: diff --git a/.opencode/agent/git-committer.md b/.opencode/agent/git-committer.md deleted file mode 100644 index 49c3e3de19..0000000000 --- a/.opencode/agent/git-committer.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: Use this agent when you are asked to commit and push code changes to a git repository. -mode: subagent ---- - -You commit and push to git - -Commit messages should be brief since they are used to generate release notes. - -Messages should say WHY the change was made and not WHAT was changed. diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index cbcbb0c651..f547e874dd 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,7 +10,13 @@ "options": {}, }, }, - "mcp": {}, + "permission": "ask", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + }, + }, "tools": { "github-triage": false, }, diff --git a/bunfig.toml b/bunfig.toml index b6874be144..36a21d9332 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,6 @@ [install] exact = true + +[test] +root = "./do-not-run-tests-from-root" + diff --git a/flake.lock b/flake.lock index 2a06923c2d..ad18c3c633 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1767151656, - "narHash": "sha256-ujL2AoYBnJBN262HD95yer7QYUmYp5kFZGYbyCCKxq8=", + "lastModified": 1767242400, + "narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f665af0cdb70ed27e1bd8f9fdfecaf451260fc55", + "rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a", "type": "github" }, "original": { diff --git a/package.json b/package.json index aa7031bec7..577ca46509 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "typecheck": "bun turbo typecheck", "prepare": "husky", "random": "echo 'Random script'", - "hello": "echo 'Hello World!'" + "hello": "echo 'Hello World!'", + "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { "packages": [ diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index dd040d8d5d..92de0a6368 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -15,7 +15,7 @@ import { type McpStatus, type LspStatus, type VcsInfo, - type Permission, + type PermissionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -46,7 +46,7 @@ type State = { [sessionID: string]: Todo[] } permission: { - [sessionID: string]: Permission[] + [sessionID: string]: PermissionRequest[] } mcp: { [name: string]: McpStatus @@ -168,7 +168,7 @@ function createGlobalSync() { vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), permission: () => sdk.permission.list().then((x) => { - const grouped: Record = {} + const grouped: Record = {} for (const perm of x.data ?? []) { if (!perm?.id || !perm.sessionID) continue const existing = grouped[perm.sessionID] @@ -349,7 +349,7 @@ function createGlobalSync() { setStore("vcs", { branch: event.properties.branch }) break } - case "permission.updated": { + case "permission.asked": { const sessionID = event.properties.sessionID const permissions = store.permission[sessionID] if (!permissions) { @@ -375,7 +375,7 @@ function createGlobalSync() { case "permission.replied": { const permissions = store.permission[event.properties.sessionID] if (!permissions) break - const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + const result = Binary.search(permissions, event.properties.requestID, (p) => p.id) if (!result.found) break setStore( "permission", diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index a0ad1ee05b..0614703611 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,7 +1,7 @@ import { createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { Permission } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import { persisted } from "@/utils/persist" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" @@ -14,10 +14,8 @@ type PermissionRespondFn = (input: { directory?: string }) => void -const AUTO_ACCEPT_TYPES = new Set(["edit", "write"]) - -function shouldAutoAccept(perm: Permission) { - return AUTO_ACCEPT_TYPES.has(perm.type) +function shouldAutoAccept(perm: PermissionRequest) { + return perm.permission === "edit" } export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ @@ -48,7 +46,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) } - function respondOnce(permission: Permission, directory?: string) { + function respondOnce(permission: PermissionRequest, directory?: string) { if (responded.has(permission.id)) return responded.add(permission.id) respond({ @@ -65,7 +63,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const unsubscribe = globalSDK.event.listen((e) => { const event = e.details - if (event?.type !== "permission.updated") return + if (event?.type !== "permission.asked") return const perm = event.properties if (!isAutoAccepting(perm.sessionID)) return @@ -98,7 +96,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return { ready, respond, - autoResponds(permission: Permission) { + autoResponds(permission: PermissionRequest) { return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission) }, isAutoAccepting, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0bcf0f7a2d..7aa1e24485 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -175,7 +175,7 @@ export default function Layout(props: ParentProps) { const permissionAlertCooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { - if (e.details?.type !== "permission.updated") return + if (e.details?.type !== "permission.asked") return const directory = e.name const perm = e.details.properties if (permission.autoResponds(perm)) return diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index e6419dd766..bab4d2b821 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -71,19 +71,19 @@ export namespace ACP { this.config.sdk.event.subscribe({ directory }).then(async (events) => { for await (const event of events.stream) { switch (event.type) { - case "permission.updated": + case "permission.asked": try { const permission = event.properties const res = await this.connection .requestPermission({ sessionId, toolCall: { - toolCallId: permission.callID ?? permission.id, + toolCallId: permission.tool?.callID ?? permission.id, status: "pending", - title: permission.title, + title: permission.permission, rawInput: permission.metadata, - kind: toToolKind(permission.type), - locations: toLocations(permission.type, permission.metadata), + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), }, options, }) @@ -93,28 +93,25 @@ export namespace ACP { permissionID: permission.id, sessionID: permission.sessionID, }) - await this.config.sdk.permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", + await this.config.sdk.permission.reply({ + requestID: permission.id, + reply: "reject", directory, }) return }) if (!res) return if (res.outcome.outcome !== "selected") { - await this.config.sdk.permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", + await this.config.sdk.permission.reply({ + requestID: permission.id, + reply: "reject", directory, }) return } - await this.config.sdk.permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: res.outcome.optionId as "once" | "always" | "reject", + await this.config.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", directory, }) } catch (err) { diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ad665e5d6e..db49b0f4fc 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,16 +4,14 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" -import { mergeDeep } from "remeda" -import { Log } from "../util/log" - -const log = Log.create({ service: "agent" }) import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" +import { PermissionNext } from "@/permission/next" +import { mergeDeep, pipe, sortBy, values } from "remeda" export namespace Agent { export const Info = z @@ -23,18 +21,10 @@ export namespace Agent { mode: z.enum(["subagent", "primary", "all"]), native: z.boolean().optional(), hidden: z.boolean().optional(), - default: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - permission: z.object({ - edit: Config.Permission, - bash: z.record(z.string(), Config.Permission), - skill: z.record(z.string(), Config.Permission), - webfetch: Config.Permission.optional(), - doom_loop: Config.Permission.optional(), - external_directory: Config.Permission.optional(), - }), + permission: PermissionNext.Ruleset, model: z .object({ modelID: z.string(), @@ -42,9 +32,8 @@ export namespace Agent { }) .optional(), prompt: z.string().optional(), - tools: z.record(z.string(), z.boolean()), options: z.record(z.string(), z.any()), - maxSteps: z.number().int().positive().optional(), + steps: z.number().int().positive().optional(), }) .meta({ ref: "Agent", @@ -53,113 +42,74 @@ export namespace Agent { const state = Instance.state(async () => { const cfg = await Config.get() - const defaultTools = cfg.tools ?? {} - const defaultPermission: Info["permission"] = { - edit: "allow", - bash: { - "*": "allow", - }, - skill: { - "*": "allow", - }, - webfetch: "allow", + + const defaults = PermissionNext.fromConfig({ + "*": "allow", doom_loop: "ask", external_directory: "ask", - } - const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) - - const planPermission = mergeAgentPermissions( - { - edit: "deny", - bash: { - "cut*": "allow", - "diff*": "allow", - "du*": "allow", - "file *": "allow", - "find * -delete*": "ask", - "find * -exec*": "ask", - "find * -fprint*": "ask", - "find * -fls*": "ask", - "find * -fprintf*": "ask", - "find * -ok*": "ask", - "find *": "allow", - "git diff*": "allow", - "git log*": "allow", - "git show*": "allow", - "git status*": "allow", - "git branch": "allow", - "git branch -v": "allow", - "grep*": "allow", - "head*": "allow", - "less*": "allow", - "ls*": "allow", - "more*": "allow", - "pwd*": "allow", - "rg*": "allow", - "sort --output=*": "ask", - "sort -o *": "ask", - "sort*": "allow", - "stat*": "allow", - "tail*": "allow", - "tree -o *": "ask", - "tree*": "allow", - "uniq*": "allow", - "wc*": "allow", - "whereis*": "allow", - "which*": "allow", - "*": "ask", - }, - webfetch: "allow", - }, - cfg.permission ?? {}, - ) + }) + const user = PermissionNext.fromConfig(cfg.permission ?? {}) const result: Record = { 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 { - const agents = await state() - const defaultCandidate = Object.values(agents).find((a) => a.default) - return defaultCandidate?.name ?? "build" + export async function defaultAgent() { + return state().then((x) => Object.keys(x)[0]) } export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { @@ -329,70 +244,3 @@ export namespace Agent { return result.object } } - -function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] { - if (typeof basePermission.bash === "string") { - basePermission.bash = { - "*": basePermission.bash, - } - } - if (typeof overridePermission.bash === "string") { - overridePermission.bash = { - "*": overridePermission.bash, - } - } - - if (typeof basePermission.skill === "string") { - basePermission.skill = { - "*": basePermission.skill, - } - } - if (typeof overridePermission.skill === "string") { - overridePermission.skill = { - "*": overridePermission.skill, - } - } - const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any - let mergedBash - if (merged.bash) { - if (typeof merged.bash === "string") { - mergedBash = { - "*": merged.bash, - } - } else if (typeof merged.bash === "object") { - mergedBash = mergeDeep( - { - "*": "allow", - }, - merged.bash, - ) - } - } - - let mergedSkill - if (merged.skill) { - if (typeof merged.skill === "string") { - mergedSkill = { - "*": merged.skill, - } - } else if (typeof merged.skill === "object") { - mergedSkill = mergeDeep( - { - "*": "allow", - }, - merged.skill, - ) - } - } - - const result: Agent.Info["permission"] = { - edit: merged.edit ?? "allow", - webfetch: merged.webfetch ?? "allow", - bash: mergedBash ?? { "*": "allow" }, - skill: mergedSkill ?? { "*": "allow" }, - doom_loop: merged.doom_loop, - external_directory: merged.external_directory, - } - - return result -} diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 60dd9cc75a..b57de0ae46 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -241,7 +241,8 @@ const AgentListCommand = cmd({ }) for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})${EOL}`) + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) } }, }) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 5a51a044df..6bd04a0eec 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,9 +1,6 @@ import { EOL } from "os" import { basename } from "path" import { Agent } from "../../../agent/agent" -import { Provider } from "../../../provider/provider" -import { ToolRegistry } from "../../../tool/registry" -import { Wildcard } from "../../../util/wildcard" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -25,27 +22,7 @@ export const AgentCommand = cmd({ ) process.exit(1) } - const resolvedTools = await resolveTools(agent) - const output = { - ...agent, - tools: resolvedTools, - toolOverrides: agent.tools, - } - process.stdout.write(JSON.stringify(output, null, 2) + EOL) + process.stdout.write(JSON.stringify(agent, null, 2) + EOL) }) }, }) - -async function resolveTools(agent: Agent.Info) { - const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID - const toolOverrides = { - ...agent.tools, - ...(await ToolRegistry.enabled(agent)), - } - const availableTools = await ToolRegistry.tools(providerID, agent) - const resolved: Record = {} - for (const tool of availableTools) { - resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false - } - return resolved -} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0c371b864c..876b64bd82 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -202,14 +202,14 @@ export const RunCommand = cmd({ break } - if (event.type === "permission.updated") { + if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue const result = await select({ - message: `Permission required to run: ${permission.title}`, + message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, options: [ { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow" }, + { value: "always", label: "Always allow: " + permission.always.join(", ") }, { value: "reject", label: "Reject" }, ], initialValue: "once", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8b7b68273a..35b33b4a09 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" -import { Global } from "@/global" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ab9487e1dd..ed0f50b2c5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -33,6 +33,7 @@ import { useKV } from "../../context/kv" export type PromptProps = { sessionID?: string + visible?: boolean disabled?: boolean onSubmit?: () => void ref?: (ref: PromptRef) => void @@ -373,7 +374,8 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { - input.focus() + if (props.visible !== false) input?.focus() + if (props.visible === false) input?.blur() }) onMount(() => { @@ -798,7 +800,7 @@ export function Prompt(props: PromptProps) { agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} /> - (anchor = r)}> + (anchor = r)} visible={props.visible !== false}> ({ - current: agents().find((x) => x.default)?.name ?? agents()[0].name, + current: agents()[0].name, }) const { theme } = useTheme() const colors = createMemo(() => [ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 2528a49989..cc4e9c69ae 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -7,7 +7,7 @@ import type { Config, Todo, Command, - Permission, + PermissionRequest, LspStatus, McpStatus, FormatterStatus, @@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: Agent[] command: Command[] permission: { - [sessionID: string]: Permission[] + [sessionID: string]: PermissionRequest[] } config: Config session: Session[] @@ -97,30 +97,10 @@ 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) - setStore( - "permission", - event.properties.sessionID, - produce((draft) => { - if (match.found) { - draft[match.index] = event.properties - return - } - draft.push(event.properties) - }), - ) - break - } - case "permission.replied": { - const permissions = store.permission[event.properties.sessionID] - const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + 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", @@ -132,6 +112,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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", + request.sessionID, + produce((draft) => { + draft.splice(match.index, 0, request) + }), + ) + break + } + case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 69082c870b..3d1315ccde 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -59,7 +59,7 @@ export function Footer() { 0}> - {permissions().length} Permission + {permissions().length} Permission {permissions().length > 1 ? "s" : ""} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 374645abb3..8a6c5cdd25 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -9,7 +9,6 @@ import { Show, Switch, useContext, - type Component, } from "solid-js" import { Dynamic } from "solid-js/web" import path from "path" @@ -23,6 +22,7 @@ import { addDefaultParsers, MacOSScrollAccel, type ScrollAcceleration, + TextAttributes, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" @@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" -import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "@tui/context/keybind" @@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" +import { PermissionPrompt } from "./permission" import { DialogExportOptions } from "../../ui/dialog-export-options" addDefaultParsers(parsers.parsers) @@ -82,12 +83,12 @@ class CustomSpeedScroll implements ScrollAcceleration { const context = createContext<{ width: number + sessionID: string conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean usernameVisible: () => boolean showDetails: () => boolean - userMessageMarkdown: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType }>() @@ -106,8 +107,17 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)!) + const children = createMemo(() => { + const parentID = session()?.parentID ?? session()?.id + return sync.data.session + .filter((x) => x.parentID === parentID || x.id === parentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) - const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) + const permissions = createMemo(() => { + if (session().parentID) return sync.data.permission[route.sessionID] ?? [] + return children().flatMap((x) => sync.data.permission[x.id] ?? []) + }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -125,7 +135,6 @@ export function Session() { const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) - const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) @@ -176,28 +185,6 @@ export function Session() { } }) - // Auto-navigate to whichever session currently needs permission input - createEffect(() => { - const currentSession = session() - if (!currentSession) return - const currentPermissions = permissions() - let targetID = currentPermissions.length > 0 ? currentSession.id : undefined - - if (!targetID) { - const child = sync.data.session.find( - (x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0, - ) - if (child) targetID = child.id - } - - if (targetID && targetID !== currentSession.id) { - navigate({ - type: "session", - sessionID: targetID, - }) - } - }) - let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -248,29 +235,6 @@ export function Session() { dialog.clear() } - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - const first = permissions()[0] - if (first) { - const response = iife(() => { - if (evt.ctrl || evt.meta) return - if (evt.name === "return") return "once" - if (evt.name === "a") return "always" - if (evt.name === "d") return "reject" - if (evt.name === "escape") return "reject" - return - }) - if (response) { - sdk.client.permission.respond({ - permissionID: first.id, - sessionID: route.sessionID, - response: response, - }) - } - } - }) - function toBottom() { setTimeout(() => { if (scroll) scroll.scrollTo(scroll.scrollHeight) @@ -280,18 +244,14 @@ export function Session() { const local = useLocal() function moveChild(direction: number) { - const parentID = session()?.parentID ?? session()?.id - let children = sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - if (children.length === 1) return - let next = children.findIndex((x) => x.id === session()?.id) + direction - if (next >= children.length) next = 0 - if (next < 0) next = children.length - 1 - if (children[next]) { + if (children().length === 1) return + let next = children().findIndex((x) => x.id === session()?.id) + direction + if (next >= children().length) next = 0 + if (next < 0) next = children().length - 1 + if (children()[next]) { navigate({ type: "session", - sessionID: children[next].id, + sessionID: children()[next].id, }) } } @@ -571,19 +531,6 @@ export function Session() { dialog.clear() }, }, - { - title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown", - value: "session.toggle.user_message_markdown", - category: "Session", - onSelect: (dialog) => { - setUserMessageMarkdown((prev) => { - const next = !prev - kv.set("user_message_markdown", next) - return next - }) - dialog.clear() - }, - }, { title: animationsEnabled() ? "Disable animations" : "Enable animations", value: "session.toggle.animations", @@ -990,12 +937,12 @@ export function Session() { get width() { return contentWidth() }, + sessionID: route.sessionID, conceal, showThinking, showTimestamps, usernameVisible, showDetails, - userMessageMarkdown, diffWrapMode, sync, }} @@ -1121,7 +1068,11 @@ export function Session() { + 0}> + + { prompt = r promptRef.set(r) @@ -1169,7 +1120,7 @@ function UserMessage(props: { const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const sync = useSync() - const { theme, syntax } = useTheme() + const { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) @@ -1200,22 +1151,7 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - - - - - - {text()?.text} - - + {text()?.text} @@ -1321,7 +1257,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las - {" "} + {" "} {Locale.titlecase(props.message.mode)} · {props.message.modelID} @@ -1397,112 +1333,77 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess // Pending messages moved to individual tool pending functions function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { - const { theme } = useTheme() - const { showDetails } = use() const sync = useSync() - const [margin, setMargin] = createSignal(0) - const component = createMemo(() => { - // Hide tool if showDetails is false and tool completed successfully - // But always show if there's an error or permission is required - const shouldHide = - !showDetails() && - props.part.state.status === "completed" && - !sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID) - if (shouldHide) { - return undefined - } + const toolprops = { + get metadata() { + return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) + }, + get input() { + return props.part.state.input ?? {} + }, + get output() { + return props.part.state.status === "completed" ? props.part.state.output : undefined + }, + get permission() { + const permissions = sync.data.permission[props.message.sessionID] ?? [] + const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID) + return permissions[permissionIndex] + }, + get tool() { + return props.part.tool + }, + get part() { + return props.part + }, + } - const render = ToolRegistry.render(props.part.tool) ?? GenericTool - - const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) - const input = props.part.state.input ?? {} - const container = ToolRegistry.container(props.part.tool) - const permissions = sync.data.permission[props.message.sessionID] ?? [] - const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID) - const permission = permissions[permissionIndex] - - const style: BoxProps = - container === "block" || permission - ? { - border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const), - paddingTop: 1, - paddingBottom: 1, - paddingLeft: 2, - marginTop: 1, - gap: 1, - backgroundColor: theme.backgroundPanel, - customBorderChars: SplitBorder.customBorderChars, - borderColor: permissionIndex === 0 ? theme.warning : theme.background, - } - : { - paddingLeft: 3, - } - - return ( - 1) { - setMargin(1) - return - } - const children = parent.getChildren() - const index = children.indexOf(el) - const previous = children[index - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.height > 1 || previous.id.startsWith("text-")) { - setMargin(1) - return - } - }} - > - - {props.part.state.status === "error" && ( - - {props.part.state.error.replace("Error: ", "")} - - )} - {permission && ( - - Permission required to run this tool: - - - enter - accept - - - a - accept always - - - d - deny - - - - )} - - ) - }) - - return {component()} + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } type ToolProps = { @@ -1511,37 +1412,16 @@ type ToolProps = { permission: Record tool: string output?: string + part: ToolPart } function GenericTool(props: ToolProps) { return ( - + {props.tool} {input(props.input)} - + ) } -type ToolRegistration = { - name: string - container: "inline" | "block" - render?: Component> -} -const ToolRegistry = (() => { - const state: Record = {} - function register(input: ToolRegistration) { - state[input.name] = input - return input - } - return { - register, - container(name: string) { - return state[name]?.container - }, - render(name: string) { - return state[name]?.render - }, - } -})() - function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { const { theme } = useTheme() return ( @@ -1553,67 +1433,135 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children: ) } -ToolRegistry.register({ - name: "bash", - container: "block", - render(props) { - const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) - const { theme } = useTheme() - return ( - <> - - {props.input.description || "Shell"} - - - $ {props.input.command} +function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) { + const [margin, setMargin] = createSignal(0) + const { theme } = useTheme() + const ctx = use() + const sync = useSync() + + const permission = createMemo(() => { + const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID + if (!callID) return false + return callID === props.part.callID + }) + + const fg = createMemo(() => { + if (permission()) return theme.warning + if (props.complete) return theme.textMuted + return theme.text + }) + + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) + + const denied = createMemo(() => error()?.includes("rejected permission")) + + return ( + 1) { + setMargin(1) + return + } + const children = parent.getChildren() + const index = children.indexOf(el) + const previous = children[index - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.height > 1 || previous.id.startsWith("text-")) { + setMargin(1) + return + } + }} + > + + ~ {props.pending}} when={props.complete}> + {props.icon} {props.children} - - + + + {error()} + + + ) +} + +function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + > + + {props.title} + + {props.children} + + ) +} + +function Bash(props: ToolProps) { + const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) + const { theme } = useTheme() + return ( + + + + + $ {props.input.command} {output()} - - - ) - }, -}) + + + + + {props.input.command} + + + + ) +} -ToolRegistry.register({ - name: "read", - container: "inline", - render(props) { - return ( - <> - - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} - - - ) - }, -}) +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const code = createMemo(() => { + if (!props.input.content) return "" + return props.input.content + }) -ToolRegistry.register({ - name: "write", - container: "block", - render(props) { - const { theme, syntax } = useTheme() - const code = createMemo(() => { - if (!props.input.content) return "" - return props.input.content - }) + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + return props.metadata.diagnostics?.[filePath] ?? [] + }) - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - return props.metadata.diagnostics?.[filePath] ?? [] - }) - - const done = !!props.input.filePath - - return ( - <> - - Wrote {props.input.filePath} - - + return ( + + + ({ content={code()} /> - - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} - - )} - - - - ) - }, -}) - -ToolRegistry.register({ - name: "glob", - container: "inline", - render(props) { - return ( - <> - - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.count} matches) - - - ) - }, -}) - -ToolRegistry.register({ - name: "grep", - container: "inline", - render(props) { - return ( - - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.matches} matches) - - ) - }, -}) - -ToolRegistry.register({ - name: "list", - container: "inline", - render(props) { - const dir = createMemo(() => { - if (props.input.path) { - return normalizePath(props.input.path) - } - return "" - }) - return ( - <> - - List {dir()} - - - ) - }, -}) - -ToolRegistry.register({ - name: "task", - container: "block", - render(props) { - const { theme } = useTheme() - const keybind = useKeybind() - const dialog = useDialog() - const renderer = useRenderer() - - return ( - <> - - {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}" - - - - - {(task, index) => { - const summary = props.metadata.summary ?? [] - return ( - - {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "} - {task.state.status === "completed" ? task.state.title : ""} - - ) - }} + + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} + + )} + + + + + + Write {normalizePath(props.input.filePath!)} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + ({props.metadata.count} matches) + + ) +} + +function Read(props: ToolProps) { + return ( + + Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + ({props.metadata.matches} matches) + + ) +} + +function List(props: ToolProps) { + const dir = createMemo(() => { + if (props.input.path) { + return normalizePath(props.input.path) + } + return "" + }) + return ( + + List {dir()} + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {(props.input as any).url} + + ) +} + +function CodeSearch(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Code Search "{input.query}" ({metadata.results} results) + + ) +} + +function WebSearch(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Web Search "{input.query}" ({metadata.numResults} results) + + ) +} + +function Task(props: ToolProps) { + const { theme } = useTheme() + const keybind = useKeybind() + const { navigate } = useRoute() + + const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) + + return ( + + + navigate({ type: "session", sessionID: props.metadata.sessionId! }) + : undefined + } + > + + + {props.input.description} ({props.metadata.summary?.length} toolcalls) + + + + └ {Locale.titlecase(current()!.tool)}{" "} + {current()!.state.status === "completed" ? current()!.state.title : ""} + + - - - {keybind.print("session_child_cycle")} - view subagents - - - ) - }, -}) + + {keybind.print("session_child_cycle")} + view subagents + + + + + + {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}" + + + + ) +} -ToolRegistry.register({ - name: "webfetch", - container: "inline", - render(props) { - return ( - - WebFetch {(props.input as any).url} - - ) - }, -}) +function Edit(props: ToolProps) { + const ctx = use() + const { theme, syntax } = useTheme() -ToolRegistry.register({ - name: "codesearch", - container: "inline", - render(props: ToolProps) { - const input = props.input as any - const metadata = props.metadata as any - return ( - - Exa Code Search "{input.query}" ({metadata.results} results) - - ) - }, -}) + const view = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + // Default to "auto" behavior + return ctx.width > 120 ? "split" : "unified" + }) -ToolRegistry.register({ - name: "websearch", - container: "inline", - render(props: ToolProps) { - const input = props.input as any - const metadata = props.metadata as any - return ( - - Exa Web Search "{input.query}" ({metadata.numResults} results) - - ) - }, -}) + const ft = createMemo(() => filetype(props.input.filePath)) -ToolRegistry.register({ - name: "edit", - container: "block", - render(props) { - const ctx = use() - const { theme, syntax } = useTheme() + const diffContent = createMemo(() => props.metadata.diff) - const view = createMemo(() => { - const diffStyle = ctx.sync.data.config.tui?.diff_style - if (diffStyle === "stacked") return "unified" - // Default to "auto" behavior - return ctx.width > 120 ? "split" : "unified" - }) + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + const arr = props.metadata.diagnostics?.[filePath] ?? [] + return arr.filter((x) => x.severity === 1).slice(0, 3) + }) - const ft = createMemo(() => filetype(props.input.filePath)) - - const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"]) - - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - const arr = props.metadata.diagnostics?.[filePath] ?? [] - return arr.filter((x) => x.severity === 1).slice(0, 3) - }) - - return ( - <> - - Edit {normalizePath(props.input.filePath!)}{" "} - {input({ - replaceAll: props.input.replaceAll, - })} - - + return ( + + + ({ removedLineNumberBg={theme.diffRemovedLineNumberBg} /> - - - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message} - - )} - - - - - ) - }, -}) + + + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "} + {diagnostic.message} + + )} + + + + + + + + Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} -ToolRegistry.register({ - name: "patch", - container: "block", - render(props) { - const { theme } = useTheme() - return ( - <> - - Patch - - +function Patch(props: ToolProps) { + const { theme } = useTheme() + return ( + + + {props.output?.trim()} - - - ) - }, -}) + + + + + Patch + + + + ) +} -ToolRegistry.register({ - name: "todowrite", - container: "block", - render(props) { - const { theme } = useTheme() - return ( - <> - - - Updating todos... - - - +function TodoWrite(props: ToolProps) { + return ( + + + {(todo) => } - - - ) - }, -}) + + + + + Updating todos... + + + + ) +} function normalizePath(input?: string) { if (!input) return "" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx new file mode 100644 index 0000000000..e3d519115b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -0,0 +1,313 @@ +import { createStore } from "solid-js/store" +import { createMemo, For, Match, Show, Switch } from "solid-js" +import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useTheme } from "../../context/theme" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useSync } from "../../context/sync" +import path from "path" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { Locale } from "@/util/locale" + +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) { + return path.relative(process.cwd(), input) || "." + } + return input +} + +function filetype(input?: string) { + if (!input) return "none" + const ext = path.extname(input) + const language = LANGUAGE_EXTENSIONS[ext] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function EditBody(props: { request: PermissionRequest }) { + const { theme, syntax } = useTheme() + const sync = useSync() + const dimensions = useTerminalDimensions() + + const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "") + const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "") + + const view = createMemo(() => { + const diffStyle = sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + return dimensions().width > 120 ? "split" : "unified" + }) + + const ft = createMemo(() => filetype(filepath())) + + return ( + + + {"→"} + Edit {normalizePath(filepath())} + + + + + + + + ) +} + +function TextBody(props: { title: string; description?: string; icon?: string }) { + const { theme } = useTheme() + return ( + <> + + + + {props.icon} + + + {props.title} + + + + {props.description} + + + + ) +} + +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 ( + + + + + + + + + This will allow the following patterns until OpenCode is restarted + + + {(pattern) => ( + + {"- "} + {pattern} + + )} + + + + + + } + options={{ confirm: "Confirm", cancel: "Cancel" }} + onSelect={(option) => { + setStore("always", false) + if (option === "cancel") return + sdk.client.permission.reply({ + reply: "always", + requestID: props.request.id, + }) + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + 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, + }) + }} + /> + + + ) +} + +function Prompt>(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 ( + + + + {"△"} + {props.title} + + {props.body} + + + + + {(option) => ( + + + {props.options[option]} + + + )} + + + + + {"⇆"} select + + + enter confirm + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 9b773111c3..79bca42406 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -99,6 +99,7 @@ function init() { replace(input: any, onClose?: () => void) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable + focus?.blur() } for (const item of store.stack) { if (item.onClose) item.onClose() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f66b467905..5d95814d7b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -123,13 +123,22 @@ export namespace Config { result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) } - if (!result.username) result.username = os.userInfo().username - - // Handle migration from autoshare to share field - if (result.autoshare === true && !result.share) { - result.share = "auto" + // Backwards compatibility: legacy top-level `tools` config + if (result.tools) { + const perms: Record = {} + 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 - export const Permission = z.enum(["ask", "allow", "deny"]) + export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ + ref: "PermissionActionConfig", + }) + export type PermissionAction = z.infer + + export const PermissionObject = z.record(z.string(), PermissionAction).meta({ + ref: "PermissionObjectConfig", + }) + export type PermissionObject = z.infer + + export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({ + ref: "PermissionRuleConfig", + }) + export type PermissionRule = z.infer + + 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 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 = { ...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 + permission?: Permission + steps?: number + } + }) .meta({ ref: "AgentConfig", }) @@ -785,16 +869,7 @@ export namespace Config { ), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), - permission: z - .object({ - edit: Permission.optional(), - bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), - skill: z.union([Permission, z.record(z.string(), Permission)]).optional(), - webfetch: Permission.optional(), - doom_loop: Permission.optional(), - external_directory: Permission.optional(), - }) - .optional(), + permission: Permission.optional(), tools: z.record(z.string(), z.boolean()).optional(), enterprise: z .object({ diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 975ca749bc..25ef79fda0 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -158,6 +158,7 @@ export namespace Installation { throw new UpgradeFailedError({ stderr: result.stderr.toString("utf8"), }) + await $`${process.execPath} --version`.nothrow().quiet().text() } export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts new file mode 100644 index 0000000000..948841c8e7 --- /dev/null +++ b/packages/opencode/src/permission/arity.ts @@ -0,0 +1,163 @@ +export namespace BashArity { + export function prefix(tokens: string[]) { + for (let len = tokens.length; len > 0; len--) { + const prefix = tokens.slice(0, len).join(" ") + const arity = ARITY[prefix] + if (arity !== undefined) return tokens.slice(0, arity) + } + if (tokens.length === 0) return [] + return tokens.slice(0, 1) + } + + /* Generated with following prompt: +You are generating a dictionary of command-prefix arities for bash-style commands. +This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command. +2. **Flags NEVER count as tokens**. Only subcommands count. +3. **Longest matching prefix wins**. +4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity. +5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical +6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed) +* `git checkout main` → `git checkout` (because `git` has arity 2) +* `npm install` → `npm install` (because `npm` has arity 2) +* `npm run dev` → `npm run dev` (because `npm run` has arity 3) +* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** +*/ + const ARITY: Record = { + cat: 1, // cat file.txt + cd: 1, // cd /path/to/dir + chmod: 1, // chmod 755 script.sh + chown: 1, // chown user:group file.txt + cp: 1, // cp source.txt dest.txt + echo: 1, // echo "hello world" + env: 1, // env + export: 1, // export PATH=/usr/bin + grep: 1, // grep pattern file.txt + kill: 1, // kill 1234 + killall: 1, // killall process + ln: 1, // ln -s source target + ls: 1, // ls -la + mkdir: 1, // mkdir new-dir + mv: 1, // mv old.txt new.txt + ps: 1, // ps aux + pwd: 1, // pwd + rm: 1, // rm file.txt + rmdir: 1, // rmdir empty-dir + sleep: 1, // sleep 5 + source: 1, // source ~/.bashrc + tail: 1, // tail -f log.txt + touch: 1, // touch file.txt + unset: 1, // unset VAR + which: 1, // which node + aws: 3, // aws s3 ls + az: 3, // az storage blob list + bazel: 2, // bazel build + brew: 2, // brew install node + bun: 2, // bun install + "bun run": 3, // bun run dev + "bun x": 3, // bun x vite + cargo: 2, // cargo build + "cargo add": 3, // cargo add tokio + "cargo run": 3, // cargo run main + cdk: 2, // cdk deploy + cf: 2, // cf push app + cmake: 2, // cmake build + composer: 2, // composer require laravel + consul: 2, // consul members + "consul kv": 3, // consul kv get config/app + crictl: 2, // crictl ps + deno: 2, // deno run server.ts + "deno task": 3, // deno task dev + doctl: 3, // doctl kubernetes cluster list + docker: 2, // docker run nginx + "docker builder": 3, // docker builder prune + "docker compose": 3, // docker compose up + "docker container": 3, // docker container ls + "docker image": 3, // docker image prune + "docker network": 3, // docker network inspect + "docker volume": 3, // docker volume ls + eksctl: 2, // eksctl get clusters + "eksctl create": 3, // eksctl create cluster + firebase: 2, // firebase deploy + flyctl: 2, // flyctl deploy + gcloud: 3, // gcloud compute instances list + gh: 3, // gh pr list + git: 2, // git checkout main + "git config": 3, // git config user.name + "git remote": 3, // git remote add origin + "git stash": 3, // git stash pop + go: 2, // go build + gradle: 2, // gradle build + helm: 2, // helm install mychart + heroku: 2, // heroku logs + hugo: 2, // hugo new site blog + ip: 2, // ip link show + "ip addr": 3, // ip addr show + "ip link": 3, // ip link set eth0 up + "ip netns": 3, // ip netns exec foo bash + "ip route": 3, // ip route add default via 1.1.1.1 + kind: 2, // kind delete cluster + "kind create": 3, // kind create cluster + kubectl: 2, // kubectl get pods + "kubectl kustomize": 3, // kubectl kustomize overlays/dev + "kubectl rollout": 3, // kubectl rollout restart deploy/api + kustomize: 2, // kustomize build . + make: 2, // make build + mc: 2, // mc ls myminio + "mc admin": 3, // mc admin info myminio + minikube: 2, // minikube start + mongosh: 2, // mongosh test + mysql: 2, // mysql -u root + mvn: 2, // mvn compile + ng: 2, // ng generate component home + npm: 2, // npm install + "npm exec": 3, // npm exec vite + "npm init": 3, // npm init vue + "npm run": 3, // npm run dev + "npm view": 3, // npm view react version + nvm: 2, // nvm use 18 + nx: 2, // nx build + openssl: 2, // openssl genrsa 2048 + "openssl req": 3, // openssl req -new -key key.pem + "openssl x509": 3, // openssl x509 -in cert.pem + pip: 2, // pip install numpy + pipenv: 2, // pipenv install flask + pnpm: 2, // pnpm install + "pnpm dlx": 3, // pnpm dlx create-next-app + "pnpm exec": 3, // pnpm exec vite + "pnpm run": 3, // pnpm run dev + poetry: 2, // poetry add requests + podman: 2, // podman run alpine + "podman container": 3, // podman container ls + "podman image": 3, // podman image prune + psql: 2, // psql -d mydb + pulumi: 2, // pulumi up + "pulumi stack": 3, // pulumi stack output + pyenv: 2, // pyenv install 3.11 + python: 2, // python -m venv env + rake: 2, // rake db:migrate + rbenv: 2, // rbenv install 3.2.0 + "redis-cli": 2, // redis-cli ping + rustup: 2, // rustup update + serverless: 2, // serverless invoke + sfdx: 3, // sfdx force:org:list + skaffold: 2, // skaffold dev + sls: 2, // sls deploy + sst: 2, // sst deploy + swift: 2, // swift build + systemctl: 2, // systemctl restart nginx + terraform: 2, // terraform apply + "terraform workspace": 3, // terraform workspace select prod + tmux: 2, // tmux new -s dev + turbo: 2, // turbo run build + ufw: 2, // ufw allow 22 + vault: 2, // vault login + "vault auth": 3, // vault auth list + "vault kv": 3, // vault kv get secret/api + vercel: 2, // vercel deploy + volta: 2, // volta install node + wp: 2, // wp plugin install + yarn: 2, // yarn add react + "yarn dlx": 3, // yarn dlx create-react-app + "yarn run": 3, // yarn run dev + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cbfeb6a9b9..f1cd43fdbe 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -27,7 +27,7 @@ export namespace Permission { sessionID: z.string(), messageID: z.string(), callID: z.string().optional(), - title: z.string(), + message: z.string(), metadata: z.record(z.string(), z.any()), time: z.object({ created: z.number(), @@ -99,7 +99,7 @@ export namespace Permission { export async function ask(input: { type: Info["type"] - title: Info["title"] + message: Info["message"] pattern?: Info["pattern"] callID?: Info["callID"] sessionID: Info["sessionID"] @@ -123,7 +123,7 @@ export namespace Permission { sessionID: input.sessionID, messageID: input.messageID, callID: input.callID, - title: input.title, + message: input.message, metadata: input.metadata, time: { created: Date.now(), diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts new file mode 100644 index 0000000000..4f7d831e75 --- /dev/null +++ b/packages/opencode/src/permission/next.ts @@ -0,0 +1,253 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config/config" +import { Identifier } from "@/id/id" +import { Instance } from "@/project/instance" +import { Storage } from "@/storage/storage" +import { fn } from "@/util/fn" +import { Log } from "@/util/log" +import { Wildcard } from "@/util/wildcard" +import z from "zod" + +export namespace PermissionNext { + const log = Log.create({ service: "permission" }) + + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", + }) + export type Action = z.infer + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer + + 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 + + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + 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(["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((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 { + const result = new Set() + for (const tool of tools) { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + if (evaluate(permission, "*", ruleset) === "deny") { + result.add(tool) + } + } + return result + } + + export class RejectedError extends Error { + constructor(public readonly reason?: string) { + super( + reason !== undefined + ? reason + : `The user rejected permission to use this specific tool call. You may try again with different parameters.`, + ) + } + } + + export async function list() { + return state().then((x) => Object.values(x.pending).map((x) => x.info)) + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 79f6094944..18a621fbbd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -78,6 +78,7 @@ export namespace Plugin { const hooks = await state().then((x) => x.hooks) const config = await Config.get() for (const hook of hooks) { + // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } Bus.subscribeAll(async (input) => { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index acd4ad5c44..9d75308c1c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -47,6 +47,7 @@ import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" +import { PermissionNext } from "@/permission/next" import { Installation } from "@/installation" import { MDNS } from "./mdns" @@ -1524,6 +1525,7 @@ export namespace Server { "/session/:sessionID/permissions/:permissionID", describeRoute({ summary: "Respond to permission", + deprecated: true, description: "Approve or deny a permission request from the AI assistant.", operationId: "permission.respond", responses: { @@ -1545,15 +1547,47 @@ export namespace Server { permissionID: z.string(), }), ), - validator("json", z.object({ response: Permission.Response })), + validator("json", z.object({ response: PermissionNext.Reply })), async (c) => { const params = c.req.valid("param") - const sessionID = params.sessionID - const permissionID = params.permissionID - Permission.respond({ - sessionID, - permissionID, - response: c.req.valid("json").response, + PermissionNext.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return c.json(true) + }, + ) + .post( + "/permission/:requestID/reply", + describeRoute({ + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.reply", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: z.string(), + }), + ), + validator("json", z.object({ reply: PermissionNext.Reply })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await PermissionNext.reply({ + requestID: params.requestID, + reply: json.reply, }) return c.json(true) }, @@ -1569,14 +1603,14 @@ export namespace Server { description: "List of pending permissions", content: { "application/json": { - schema: resolver(Permission.Info.array()), + schema: resolver(PermissionNext.Request.array()), }, }, }, }, }), async (c) => { - const permissions = Permission.list() + const permissions = await PermissionNext.list() return c.json(permissions) }, ) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4285223bc5..0776590d6a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -18,6 +18,7 @@ import { Command } from "../command" import { Snapshot } from "@/snapshot" import type { Provider } from "@/provider/provider" +import { PermissionNext } from "@/permission/next" export namespace Session { const log = Log.create({ service: "session" }) @@ -62,6 +63,7 @@ export namespace Session { compacting: z.number().optional(), archived: z.number().optional(), }), + permission: PermissionNext.Ruleset.optional(), revert: z .object({ messageID: z.string(), @@ -126,6 +128,7 @@ export namespace Session { .object({ parentID: Identifier.schema("session").optional(), title: z.string().optional(), + permission: Info.shape.permission, }) .optional(), async (input) => { @@ -133,6 +136,7 @@ export namespace Session { parentID: input?.parentID, directory: Instance.directory, title: input?.title, + permission: input?.permission, }) }, ) @@ -174,7 +178,13 @@ export namespace Session { }) }) - export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) { + export async function createNext(input: { + id?: string + title?: string + parentID?: string + directory: string + permission?: PermissionNext.Ruleset + }) { const result: Info = { id: Identifier.descending("session", input.id), version: Installation.VERSION, @@ -182,6 +192,7 @@ export namespace Session { directory: input.directory, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), + permission: input.permission, time: { created: Date.now(), updated: Date.now(), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ccd7af1f0f..fc701588d5 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" -import { ToolRegistry } from "@/tool/registry" import { Flag } from "@/flag/flag" +import { PermissionNext } from "@/permission/next" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -200,13 +200,11 @@ export namespace LLM { } async function resolveTools(input: Pick) { - const enabled = pipe( - input.agent.tools, - mergeDeep(await ToolRegistry.enabled(input.agent)), - mergeDeep(input.user.tools ?? {}), - ) - for (const [key, value] of Object.entries(enabled)) { - if (value === false) delete input.tools[key] + const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) + for (const tool of Object.keys(input.tools)) { + if (input.user.tools?.[tool] === false || disabled.has(tool)) { + delete input.tools[tool] + } } return input.tools } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 567b964793..227ca64bb9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -3,7 +3,6 @@ import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." import { Agent } from "@/agent/agent" -import { Permission } from "@/permission" import { Snapshot } from "@/snapshot" import { SessionSummary } from "./summary" import { Bus } from "@/bus" @@ -14,6 +13,7 @@ import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" +import { PermissionNext } from "@/permission/next" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -152,32 +152,18 @@ export namespace SessionProcessor { JSON.stringify(p.state.input) === JSON.stringify(value.input), ) ) { - const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission) - if (permission.doom_loop === "ask") { - await Permission.ask({ - type: "doom_loop", - pattern: value.toolName, - sessionID: input.assistantMessage.sessionID, - messageID: input.assistantMessage.id, - callID: value.toolCallId, - title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, - metadata: { - tool: value.toolName, - input: value.input, - }, - }) - } else if (permission.doom_loop === "deny") { - throw new Permission.RejectedError( - input.assistantMessage.sessionID, - "doom_loop", - value.toolCallId, - { - tool: value.toolName, - input: value.input, - }, - `You seem to be stuck in a doom loop, please stop repeating the same action`, - ) - } + const agent = await Agent.get(input.assistantMessage.agent) + await PermissionNext.ask({ + permission: "doom_loop", + patterns: [value.toolName], + sessionID: input.assistantMessage.sessionID, + metadata: { + tool: value.toolName, + input: value.input, + }, + always: [value.toolName], + ruleset: agent.permission, + }) } } break @@ -215,7 +201,6 @@ export namespace SessionProcessor { status: "error", input: value.input, error: (value.error as any).toString(), - metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, time: { start: match.state.time.start, end: Date.now(), @@ -223,7 +208,7 @@ export namespace SessionProcessor { }, }) - if (value.error instanceof Permission.RejectedError) { + if (value.error instanceof PermissionNext.RejectedError) { blocked = shouldBreak } delete toolcalls[value.toolCallId] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3171192a35..d4fef6f7a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,7 +9,7 @@ import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" -import { type Tool as AITool, tool, jsonSchema } from "ai" +import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" @@ -20,9 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { clone, mergeDeep, pipe } from "remeda" +import { clone } from "remeda" import { ToolRegistry } from "../tool/registry" -import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -39,6 +38,8 @@ import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" +import { Tool } from "@/tool/tool" +import { PermissionNext } from "@/permission/next" import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" @@ -88,7 +89,12 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), - tools: z.record(z.string(), z.boolean()).optional(), + tools: z + .record(z.string(), z.boolean()) + .optional() + .describe( + "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + ), system: z.string().optional(), variant: z.string().optional(), parts: z.array( @@ -145,6 +151,23 @@ export namespace SessionPrompt { const message = await createUserMessage(input) await Session.touch(input.sessionID) + // this is backwards compatibility for allowing `tools` to be specified when + // prompting + const permissions: PermissionNext.Ruleset = [] + for (const [tool, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ + permission: tool, + action: enabled ? "allow" : "deny", + pattern: "*", + }) + } + if (permissions.length > 0) { + session.permission = permissions + await Session.update(session.id, (draft) => { + draft.permission = permissions + }) + } + if (input.noReply === true) { return message } @@ -240,6 +263,7 @@ export namespace SessionPrompt { using _ = defer(() => cancel(sessionID)) let step = 0 + const session = await Session.get(sessionID) while (true) { SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) @@ -276,7 +300,7 @@ export namespace SessionPrompt { step++ if (step === 1) ensureTitle({ - session: await Session.get(sessionID), + session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, message: msgs.find((m) => m.info.role === "user")!, @@ -350,28 +374,35 @@ export namespace SessionPrompt { { args: taskArgs }, ) let executionError: Error | undefined - const result = await taskTool - .execute(taskArgs, { - agent: task.agent, - messageID: assistantMessage.id, - sessionID: sessionID, - abort, - async metadata(input) { - await Session.updatePart({ - ...part, - type: "tool", - state: { - ...part.state, - ...input, - }, - } satisfies MessageV2.ToolPart) - }, - }) - .catch((error) => { - executionError = error - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }) + const taskAgent = await Agent.get(task.agent) + const taskCtx: Tool.Context = { + agent: task.agent, + messageID: assistantMessage.id, + sessionID: sessionID, + abort, + async metadata(input) { + await Session.updatePart({ + ...part, + type: "tool", + state: { + ...part.state, + ...input, + }, + } satisfies MessageV2.ToolPart) + }, + async ask(req) { + await PermissionNext.ask({ + ...req, + sessionID: sessionID, + ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), + }) + }, + } + const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { + executionError = error + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }) await Plugin.trigger( "tool.execute.after", { @@ -473,7 +504,7 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) - const maxSteps = agent.maxSteps ?? Infinity + const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = insertReminders({ messages: msgs, @@ -511,7 +542,7 @@ export namespace SessionPrompt { }) const tools = await resolveTools({ agent, - sessionID, + session, model, tools: lastUser.tools, processor, @@ -581,67 +612,73 @@ export namespace SessionPrompt { async function resolveTools(input: { agent: Agent.Info model: Provider.Model - sessionID: string + session: Session.Info tools?: Record processor: SessionProcessor.Info }) { using _ = log.time("resolveTools") const tools: Record = {} - const enabledTools = pipe( - input.agent.tools, - mergeDeep(await ToolRegistry.enabled(input.agent)), - mergeDeep(input.tools ?? {}), - ) - for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { - if (Wildcard.all(item.id, enabledTools) === false) continue + + const context = (args: any, options: ToolCallOptions): Tool.Context => ({ + sessionID: input.session.id, + abort: options.abortSignal!, + messageID: input.processor.message.id, + callID: options.toolCallId, + extra: { model: input.model }, + agent: input.agent.name, + metadata: async (val: { title?: string; metadata?: any }) => { + const match = input.processor.partFromToolCall(options.toolCallId) + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + title: val.title, + metadata: val.metadata, + status: "running", + input: args, + time: { + start: Date.now(), + }, + }, + }) + } + }, + async ask(req) { + await PermissionNext.ask({ + ...req, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + }) + }, + }) + + for (const item of await ToolRegistry.tools(input.model.providerID)) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, description: item.description, inputSchema: jsonSchema(schema as any), async execute(args, options) { + const ctx = context(args, options) await Plugin.trigger( "tool.execute.before", { tool: item.id, - sessionID: input.sessionID, - callID: options.toolCallId, + sessionID: ctx.sessionID, + callID: ctx.callID, }, { args, }, ) - const result = await item.execute(args, { - sessionID: input.sessionID, - abort: options.abortSignal!, - messageID: input.processor.message.id, - callID: options.toolCallId, - extra: { model: input.model }, - agent: input.agent.name, - metadata: async (val) => { - const match = input.processor.partFromToolCall(options.toolCallId) - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { - start: Date.now(), - }, - }, - }) - } - }, - }) + const result = await item.execute(args, ctx) await Plugin.trigger( "tool.execute.after", { tool: item.id, - sessionID: input.sessionID, - callID: options.toolCallId, + sessionID: ctx.sessionID, + callID: ctx.callID, }, result, ) @@ -655,31 +692,41 @@ export namespace SessionPrompt { }, }) } + for (const [key, item] of Object.entries(await MCP.tools())) { - if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute if (!execute) continue // Wrap execute to add plugin hooks and format output item.execute = async (args, opts) => { + const ctx = context(args, opts) + await Plugin.trigger( "tool.execute.before", { tool: key, - sessionID: input.sessionID, + sessionID: ctx.sessionID, callID: opts.toolCallId, }, { args, }, ) + + await ctx.ask({ + permission: key, + metadata: {}, + patterns: ["*"], + always: ["*"], + }) + const result = await execute(args, opts) await Plugin.trigger( "tool.execute.after", { tool: key, - sessionID: input.sessionID, + sessionID: ctx.sessionID, callID: opts.toolCallId, }, result, @@ -694,7 +741,7 @@ export namespace SessionPrompt { } else if (contentItem.type === "image") { attachments.push({ id: Identifier.ascending("part"), - sessionID: input.sessionID, + sessionID: input.session.id, messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, @@ -834,14 +881,16 @@ export namespace SessionPrompt { await ReadTool.init() .then(async (t) => { const model = await Provider.getModel(info.model.providerID, info.model.modelID) - const result = await t.execute(args, { + const readCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, extra: { bypassCwdCheck: true, model }, metadata: async () => {}, - }) + ask: async () => {}, + } + const result = await t.execute(args, readCtx) pieces.push({ id: Identifier.ascending("part"), messageID: info.id, @@ -893,16 +942,16 @@ export namespace SessionPrompt { if (part.mime === "application/x-directory") { const args = { path: filepath } - const result = await ListTool.init().then((t) => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - metadata: async () => {}, - }), - ) + const listCtx: Tool.Context = { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + metadata: async () => {}, + ask: async () => {}, + } + const result = await ListTool.init().then((t) => t.execute(args, listCtx)) return [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index e10ee3bad3..f9ac12a2bb 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -44,7 +44,7 @@ export namespace SystemPrompt { ``, ``, ` ${ - project.vcs === "git" + project.vcs === "git" && false ? await Ripgrep.tree({ cwd: Instance.directory, limit: 200, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 92d4ced0f7..6671c939c4 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,16 +6,15 @@ import { Log } from "../util/log" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" -import { Agent } from "@/agent/agent" + import { $ } from "bun" import { Filesystem } from "@/util/filesystem" -import { Wildcard } from "@/util/wildcard" -import { Permission } from "@/permission" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" -import path from "path" import { Shell } from "@/shell/shell" +import { BashArity } from "@/permission/arity" + const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -81,41 +80,11 @@ export const BashTool = Tool.define("bash", async () => { if (!tree) { throw new Error("Failed to parse command") } - const agent = await Agent.get(ctx.agent) + const directories = new Set() + if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd) + const patterns = new Set() + const always = new Set() - 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 permissions = agent.permission.bash - - const askPatterns = new Set() for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue const command = [] @@ -150,48 +119,33 @@ export const BashTool = Tool.define("bash", async () => { process.platform === "win32" && resolved.match(/^\/[a-z]\//) ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") : resolved - - await checkExternalDirectory(normalized) + directories.add(normalized) } } } - // always allow cd if it passes above check - if (command[0] !== "cd") { - const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions) - if (action === "deny") { - throw new Error( - `The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`, - ) - } - if (action === "ask") { - const pattern = (() => { - if (command.length === 0) return - const head = command[0] - // Find first non-flag argument as subcommand - const sub = command.slice(1).find((arg) => !arg.startsWith("-")) - return sub ? `${head} ${sub} *` : `${head} *` - })() - if (pattern) { - askPatterns.add(pattern) - } - } + // cd covered by above check + if (command.length && command[0] !== "cd") { + patterns.add(command.join(" ")) + always.add(BashArity.prefix(command).join(" ") + "*") } } - if (askPatterns.size > 0) { - const patterns = Array.from(askPatterns) - await Permission.ask({ - type: "bash", - pattern: patterns, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: params.command, - metadata: { - command: params.command, - patterns, - }, + if (directories.size > 0) { + await ctx.ask({ + permission: "external_directory", + patterns: Array.from(directories), + always: Array.from(directories).map((x) => x + "*"), + metadata: {}, + }) + } + + if (patterns.size > 0) { + await ctx.ask({ + permission: "bash", + patterns: Array.from(patterns), + always: Array.from(always), + metadata: {}, }) } diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 0227c06f5d..369cdb4504 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,8 +1,6 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./codesearch.txt" -import { Config } from "../config/config" -import { Permission } from "../permission" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -52,19 +50,15 @@ export const CodeSearchTool = Tool.define("codesearch", { ), }), async execute(params, ctx) { - const cfg = await Config.get() - if (cfg.permission?.webfetch === "ask") - await Permission.ask({ - type: "codesearch", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Search code for: " + params.query, - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) + await ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) const codeRequest: McpCodeRequest = { jsonrpc: "2.0", diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 6267997464..787282ecd0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -8,14 +8,12 @@ import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" -import { Permission } from "../permission" import DESCRIPTION from "./edit.txt" import { File } from "../file" import { Bus } from "../bus" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" -import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -41,36 +39,18 @@ export const EditTool = Tool.define("edit", { throw new Error("oldString and newString must be different") } - const agent = await Agent.get(ctx.agent) - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Edit file outside working directory: ${filePath}`, - metadata: { - filepath: filePath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filePath, - parentDir, - }, - `File ${filePath} is not in the current working directory`, - ) - } + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir, path.join(parentDir, "*")], + always: [parentDir + "/*"], + metadata: { + filepath: filePath, + parentDir, + }, + }) } let diff = "" @@ -80,19 +60,15 @@ export const EditTool = Tool.define("edit", { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Edit this file: " + filePath, - metadata: { - filePath, - diff, - }, - }) - } + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, @@ -112,19 +88,15 @@ export const EditTool = Tool.define("edit", { diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Edit this file: " + filePath, - metadata: { - filePath, - diff, - }, - }) - } + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) await file.write(contentNew) await Bus.publish(File.Event.Edited, { @@ -137,6 +109,26 @@ export const EditTool = Tool.define("edit", { FileTime.read(ctx.sessionID, filePath) }) + const filediff: Snapshot.FileDiff = { + file: filePath, + before: contentOld, + after: contentNew, + additions: 0, + deletions: 0, + } + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) filediff.additions += change.count || 0 + if (change.removed) filediff.deletions += change.count || 0 + } + + ctx.metadata({ + metadata: { + diff, + filediff, + diagnostics: {}, + }, + }) + let output = "" await LSP.touchFile(filePath, true) const diagnostics = await LSP.diagnostics() @@ -150,18 +142,6 @@ export const EditTool = Tool.define("edit", { output += `\nThis file has errors, please fix\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n` } - const filediff: Snapshot.FileDiff = { - file: filePath, - before: contentOld, - after: contentNew, - additions: 0, - deletions: 0, - } - for (const change of diffLines(contentOld, contentNew)) { - if (change.added) filediff.additions += change.count || 0 - if (change.removed) filediff.deletions += change.count || 0 - } - return { metadata: { diagnostics, diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 11c12f19ac..0c643796de 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -16,7 +16,17 @@ export const GlobTool = Tool.define("glob", { `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, ), }), - async execute(params) { + async execute(params, ctx) { + await ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + }, + }) + let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index d73bc16168..4cbc5347f5 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -14,11 +14,22 @@ export const GrepTool = Tool.define("grep", { path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), }), - async execute(params) { + async execute(params, ctx) { if (!params.pattern) { throw new Error("pattern is required") } + await ctx.ask({ + permission: "grep", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + include: params.include, + }, + }) + const searchPath = params.path || Instance.directory const rgPath = await Ripgrep.filepath() diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 95c36e7459..b8638b3e90 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -40,9 +40,18 @@ export const ListTool = Tool.define("list", { path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), }), - async execute(params) { + async execute(params, ctx) { const searchPath = path.resolve(Instance.directory, params.path || ".") + await ctx.ask({ + permission: "list", + patterns: [searchPath], + always: ["*"], + metadata: { + path: searchPath, + }, + }) + const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = [] for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) { diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 2a15ed7e33..df4692bf6d 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -26,7 +26,14 @@ export const LspTool = Tool.define("lsp", { line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), }), - execute: async (args) => { + execute: async (args, ctx) => { + await ctx.ask({ + permission: "lsp", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) const uri = pathToFileURL(file).href const position = { diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd..62d9f70f20 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -3,11 +3,9 @@ import * as path from "path" import * as fs from "fs/promises" import { Tool } from "./tool" import { FileTime } from "../file/time" -import { Permission } from "../permission" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" -import { Agent } from "../agent/agent" import { Patch } from "../patch" import { Filesystem } from "../util/filesystem" import { createTwoFilesPatch } from "diff" @@ -39,7 +37,6 @@ export const PatchTool = Tool.define("patch", { } // Validate file paths and check permissions - const agent = await Agent.get(ctx.agent) const fileChanges: Array<{ filePath: string oldContent: string @@ -55,31 +52,15 @@ export const PatchTool = Tool.define("patch", { if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Patch file outside working directory: ${filePath}`, - metadata: { - filepath: filePath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filePath, - parentDir, - }, - `File ${filePath} is not in the current working directory`, - ) - } + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir, path.join(parentDir, "*")], + always: [parentDir + "/*"], + metadata: { + filepath: filePath, + parentDir, + }, + }) } switch (hunk.type) { @@ -152,18 +133,14 @@ export const PatchTool = Tool.define("patch", { } // Check permissions if needed - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Apply patch to ${fileChanges.length} files`, - metadata: { - diff: totalDiff, - }, - }) - } + await ctx.ask({ + permission: "edit", + patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), + always: ["*"], + metadata: { + diff: totalDiff, + }, + }) // Apply the changes const changedFiles: string[] = [] diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fd81c4864a..847fe3ebe7 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,8 +8,6 @@ import DESCRIPTION from "./read.txt" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Identifier } from "../id/id" -import { Permission } from "../permission" -import { Agent } from "@/agent/agent" import { iife } from "@/util/iife" const DEFAULT_READ_LIMIT = 2000 @@ -28,37 +26,27 @@ export const ReadTool = Tool.define("read", { filepath = path.join(process.cwd(), filepath) } const title = path.relative(Instance.worktree, filepath) - const agent = await Agent.get(ctx.agent) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Access file outside working directory: ${filepath}`, - metadata: { - filepath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filepath, - parentDir, - }, - `File ${filepath} is not in the current working directory`, - ) - } + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir], + always: [parentDir + "/*"], + metadata: { + filepath, + parentDir, + }, + }) } + await ctx.ask({ + permission: "read", + patterns: [filepath], + always: ["*"], + metadata: {}, + }) + const block = iife(() => { const basename = path.basename(filepath) const whitelist = [".env.sample", ".env.example", ".example", ".env.template"] diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f975d52a0d..db51528474 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -2,7 +2,6 @@ import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" -import { ListTool } from "./ls" import { BatchTool } from "./batch" import { ReadTool } from "./read" import { TaskTool } from "./task" @@ -135,27 +134,4 @@ export namespace ToolRegistry { ) return result } - - export async function enabled(agent: Agent.Info): Promise> { - const result: Record = {} - - if (agent.permission.edit === "deny") { - result["edit"] = false - result["write"] = false - } - if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) { - result["bash"] = false - } - if (agent.permission.webfetch === "deny") { - result["webfetch"] = false - result["codesearch"] = false - result["websearch"] = false - } - // Disable skill tool if all skills are denied - if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) { - result["skill"] = false - } - - return result - } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index b56276f61e..00a081eaca 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -2,21 +2,13 @@ import path from "path" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" -import { Agent } from "../agent/agent" -import { Permission } from "../permission" -import { Wildcard } from "../util/wildcard" import { ConfigMarkdown } from "../config/markdown" -const parameters = z.object({ - name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), -}) +export const SkillTool = Tool.define("skill", async () => { + const skills = await Skill.all() -export const SkillTool: Tool.Info = { - id: "skill", - async init(ctx) { - 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 = { 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.", - "", - ...accessibleSkills.flatMap((skill) => [ - ` `, - ` ${skill.name}`, - ` ${skill.description}`, - ` `, - ]), - "", - ].join(" ") + 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.", + "", + ...skills.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` `, + ]), + "", + ].join(" ") - return { - description, - parameters, - async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) + 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) - const skill = await Skill.get(params.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 (!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"}`) - } + 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) - // Check permission using Wildcard.all on the skill name - const permissions = agent.permission.skill - const action = Wildcard.all(params.name, permissions) + // Format output similar to plugin pattern + const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n") - 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 (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) - - // Format output similar to plugin pattern - const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join( - "\n", - ) - - return { - title: `Loaded skill: ${skill.name}`, - output, - metadata: { - name: skill.name, - dir, - }, - } - }, - } - }, -} + return { + title: `Loaded skill: ${skill.name}`, + output, + metadata: { + name: skill.name, + dir, + }, + } + }, + } +}) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bc93f497a9..112edc3dc8 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -29,6 +29,17 @@ export const TaskTool = Tool.define("task", async () => { command: z.string().describe("The command that triggered this task").optional(), }), async execute(params, ctx) { + const config = await Config.get() + await ctx.ask({ + permission: "task", + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, + }) + const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) const session = await iife(async () => { @@ -40,6 +51,28 @@ export const TaskTool = Tool.define("task", async () => { return await Session.create({ parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, + permission: [ + { + permission: "todowrite", + pattern: "*", + action: "deny", + }, + { + permission: "todoread", + pattern: "*", + action: "deny", + }, + { + permission: "task", + pattern: "*", + action: "deny", + }, + ...(config.experimental?.primary_tools?.map((t) => ({ + pattern: "*", + action: "allow" as const, + permission: t, + })) ?? []), + ], }) }) const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) @@ -88,7 +121,6 @@ export const TaskTool = Tool.define("task", async () => { using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const config = await Config.get() const result = await SessionPrompt.prompt({ messageID, sessionID: session.id, @@ -102,7 +134,6 @@ export const TaskTool = Tool.define("task", async () => { todoread: false, task: false, ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), - ...agent.tools, }, parts: promptParts, }) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index cea8d53228..440f1563c7 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -8,9 +8,16 @@ export const TodoWriteTool = Tool.define("todowrite", { parameters: z.object({ todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), }), - async execute(params, opts) { + async execute(params, ctx) { + await ctx.ask({ + permission: "todowrite", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + await Todo.update({ - sessionID: opts.sessionID, + sessionID: ctx.sessionID, todos: params.todos, }) return { @@ -26,8 +33,15 @@ export const TodoWriteTool = Tool.define("todowrite", { export const TodoReadTool = Tool.define("todoread", { description: "Use this tool to read your todo list", parameters: z.object({}), - async execute(_params, opts) { - const todos = await Todo.get(opts.sessionID) + async execute(_params, ctx) { + await ctx.ask({ + permission: "todoread", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + const todos = await Todo.get(ctx.sessionID) return { title: `${todos.filter((x) => x.status !== "completed").length} todos`, metadata: { diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index acee24902c..434a3d4266 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,6 +1,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" +import type { PermissionNext } from "../permission/next" export namespace Tool { interface Metadata { @@ -19,6 +20,7 @@ export namespace Tool { callID?: string extra?: { [key: string]: any } metadata(input: { title?: string; metadata?: M }): void + ask(input: Omit): Promise } export interface Info { id: string diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index cf1940bf86..634c68f4ee 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -2,8 +2,6 @@ import z from "zod" import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" -import { Config } from "../config/config" -import { Permission } from "../permission" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -25,20 +23,16 @@ export const WebFetchTool = Tool.define("webfetch", { throw new Error("URL must start with http:// or https://") } - const cfg = await Config.get() - if (cfg.permission?.webfetch === "ask") - await Permission.ask({ - type: "webfetch", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Fetch content from: " + params.url, - metadata: { - url: params.url, - format: params.format, - timeout: params.timeout, - }, - }) + await ctx.ask({ + permission: "webfetch", + patterns: [params.url], + always: ["*"], + metadata: { + url: params.url, + format: params.format, + timeout: params.timeout, + }, + }) const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 4064d12f38..f6df36f10f 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,6 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./websearch.txt" -import { Config } from "../config/config" -import { Permission } from "../permission" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -59,22 +57,18 @@ export const WebSearchTool = Tool.define("websearch", { .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), }), async execute(params, ctx) { - const cfg = await Config.get() - if (cfg.permission?.webfetch === "ask") - await Permission.ask({ - type: "websearch", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Search web for: " + params.query, - metadata: { - query: params.query, - numResults: params.numResults, - livecrawl: params.livecrawl, - type: params.type, - contextMaxCharacters: params.contextMaxCharacters, - }, - }) + await ctx.ask({ + permission: "websearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + numResults: params.numResults, + livecrawl: params.livecrawl, + type: params.type, + contextMaxCharacters: params.contextMaxCharacters, + }, + }) const searchRequest: McpSearchRequest = { jsonrpc: "2.0", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a0e87299a4..a0ca6b14f7 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -2,14 +2,14 @@ import z from "zod" import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" -import { Permission } from "../permission" +import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { Bus } from "../bus" import { File } from "../file" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" -import { Agent } from "../agent/agent" +import { trimDiff } from "./edit" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -21,55 +21,29 @@ export const WriteTool = Tool.define("write", { filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), }), async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + /* TODO if (!Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Write file outside working directory: ${filepath}`, - metadata: { - filepath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filepath, - parentDir, - }, - `File ${filepath} is not in the current working directory`, - ) - } + ... } + */ const file = Bun.file(filepath) const exists = await file.exists() + const contentOld = exists ? await file.text() : "" if (exists) await FileTime.assert(ctx.sessionID, filepath) - if (agent.permission.edit === "ask") - await Permission.ask({ - type: "write", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath, - metadata: { - filePath: filepath, - content: params.content, - exists, - }, - }) + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filepath)], + always: ["*"], + metadata: { + filepath, + diff, + }, + }) await Bun.write(filepath, params.content) await Bus.publish(File.Event.Edited, { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 222bf8367e..c11ebfbf07 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,11 +1,16 @@ import { test, expect } from "bun:test" -import path from "path" -import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" +import { PermissionNext } from "../../src/permission/next" -test("loads built-in agents when no custom agents configured", async () => { +// Helper to evaluate permission for a tool with wildcard pattern +function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { + if (!agent) return undefined + return PermissionNext.evaluate(permission, "*", agent.permission) +} + +test("returns default native agents when no config", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -14,133 +19,430 @@ test("loads built-in agents when no custom agents configured", async () => { const names = agents.map((a) => a.name) expect(names).toContain("build") expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") }, }) }) -test("custom subagent works alongside built-in primary agents", 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, "helper.md"), - `--- -model: test/model -mode: subagent ---- -Helper subagent prompt`, - ) - }, - }) +test("build agent has correct default properties", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const agents = await Agent.list() - const helper = agents.find((a) => a.name === "helper") - expect(helper).toBeDefined() - expect(helper?.mode).toBe("subagent") - - // Built-in primary agents should still exist - const build = agents.find((a) => a.name === "build") + 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("throws error when all primary agents are disabled", 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 }, - }, - }), - ) - }, - }) +test("plan agent denies edits except .opencode/plan/*", 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") - } - }, - }) -}) - -test("does not throw when at least one primary agent remains", 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 }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await Agent.list() - const plan = agents.find((a) => a.name === "plan") + const plan = await Agent.get("plan") expect(plan).toBeDefined() - expect(plan?.mode).toBe("primary") + // 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("custom primary agent satisfies requirement when built-ins disabled", async () => { +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 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 }) - - 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: { + 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 agents = await Agent.list() - const custom = agents.find((a) => a.name === "custom") + const custom = await Agent.get("my_custom_agent") expect(custom).toBeDefined() - expect(custom?.mode).toBe("primary") + 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) + }, + }) +}) + +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 names = agents.map((a) => a.name) + expect(names).not.toContain("explore") + }, + }) +}) + +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() + // 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("agent steps/maxSteps config sets steps property", async () => { + await using tmp = await tmpdir({ + 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 () => { + const build = await Agent.get("build") + expect(evalPerm(build, "webfetch")).toBe("allow") + }, + }) +}) + +test("legacy tools config converts to permissions", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { + tools: { + bash: false, + read: false, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(evalPerm(build, "bash")).toBe("deny") + expect(evalPerm(build, "read")).toBe("deny") + }, + }) +}) + +test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { + tools: { + write: false, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(evalPerm(build, "edit")).toBe("deny") }, }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8871fd50ba..c2ed3abe8f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -205,11 +205,13 @@ test("handles agent configuration", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.agent?.["test_agent"]).toEqual({ - model: "test/model", - temperature: 0.7, - description: "test agent", - }) + expect(config.agent?.["test_agent"]).toEqual( + expect.objectContaining({ + model: "test/model", + temperature: 0.7, + description: "test agent", + }), + ) }, }) }) @@ -292,6 +294,8 @@ test("migrates mode field to agent field", async () => { model: "test/model", temperature: 0.5, mode: "primary", + options: {}, + permission: {}, }) }, }) @@ -318,11 +322,13 @@ Test agent prompt`, directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.agent?.["test"]).toEqual({ - name: "test", - model: "test/model", - prompt: "Test agent prompt", - }) + expect(config.agent?.["test"]).toEqual( + expect.objectContaining({ + name: "test", + model: "test/model", + prompt: "Test agent prompt", + }), + ) }, }) }) @@ -472,7 +478,7 @@ Helper subagent prompt`, directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.agent?.["helper"]).toEqual({ + expect(config.agent?.["helper"]).toMatchObject({ name: "helper", model: "test/model", mode: "subagent", @@ -534,36 +540,22 @@ test("deduplicates duplicate plugins from global and local configs", async () => }) }) -test("compaction config defaults to true when not specified", 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", - }), - ) - }, - }) - await Instance.provide({ - 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() - }, - }) -}) +// Legacy tools migration tests -test("compaction config can disable auto compaction", async () => { +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", - compaction: { - auto: false, + agent: { + test: { + tools: { + bash: true, + read: true, + }, + }, }, }), ) @@ -573,21 +565,28 @@ 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({ + bash: "allow", + read: "allow", + }) }, }) }) -test("compaction config can disable prune", async () => { +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", - compaction: { - prune: false, + agent: { + test: { + tools: { + bash: false, + webfetch: false, + }, + }, }, }), ) @@ -597,22 +596,27 @@ 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: "deny", + webfetch: "deny", + }) }, }) }) -test("compaction config can disable both auto and prune", async () => { +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", - compaction: { - auto: false, - prune: false, + agent: { + test: { + tools: { + write: true, + }, + }, }, }), ) @@ -622,8 +626,164 @@ 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({ + 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, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "allow", + }) + }, + }) +}) + +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", + agent: { + test: { + tools: { + multiedit: false, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "deny", + }) + }, + }) +}) + +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", + agent: { + test: { + tools: { + bash: true, + write: true, + read: false, + webfetch: true, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "allow", + edit: "allow", + read: "deny", + webfetch: "allow", + }) + }, + }) +}) + +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", + agent: { + test: { + permission: { + glob: "allow", + }, + tools: { + bash: true, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + glob: "allow", + bash: "allow", + }) }, }) }) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 31cf0ae993..ed8c5e344a 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import * as fs from "fs/promises" import os from "os" import path from "path" +import type { Config } from "../../src/config/config" // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -10,6 +11,7 @@ function sanitizePath(p: string): string { type TmpDirOptions = { git?: boolean + config?: Partial init?: (dir: string) => Promise dispose?: (dir: string) => Promise } @@ -20,6 +22,15 @@ export async function tmpdir(options?: TmpDirOptions) { await $`git init`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } + if (options?.config) { + await Bun.write( + path.join(dirpath, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + ...options.config, + }), + ) + } const extra = await options?.init?.(dirpath) const realpath = sanitizePath(await fs.realpath(dirpath)) const result = { diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts new file mode 100644 index 0000000000..634e41e724 --- /dev/null +++ b/packages/opencode/test/permission/arity.test.ts @@ -0,0 +1,33 @@ +import { test, expect } from "bun:test" +import { BashArity } from "../../src/permission/arity" + +test("arity 1 - unknown commands default to first token", () => { + expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"]) + expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"]) +}) + +test("arity 2 - two token commands", () => { + expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]) + expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"]) +}) + +test("arity 3 - three token commands", () => { + expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"]) + expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"]) +}) + +test("longest match wins - nested prefixes", () => { + expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"]) + expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"]) +}) + +test("exact length matches", () => { + expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"]) + expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]) +}) + +test("edge cases", () => { + expect(BashArity.prefix([])).toEqual([]) + expect(BashArity.prefix(["single"])).toEqual(["single"]) + expect(BashArity.prefix(["git"])).toEqual(["git"]) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts new file mode 100644 index 0000000000..31af4cd450 --- /dev/null +++ b/packages/opencode/test/permission/next.test.ts @@ -0,0 +1,652 @@ +import { test, expect } from "bun:test" +import { PermissionNext } from "../../src/permission/next" +import { Instance } from "../../src/project/instance" +import { Storage } from "../../src/storage/storage" +import { tmpdir } from "../fixture/fixture" + +// fromConfig tests + +test("fromConfig - string value becomes wildcard rule", () => { + const result = PermissionNext.fromConfig({ bash: "allow" }) + expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) +}) + +test("fromConfig - object value converts to rules array", () => { + const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "deny" }, + ]) +}) + +test("fromConfig - mixed string and object values", () => { + const result = PermissionNext.fromConfig({ + bash: { "*": "allow", rm: "deny" }, + edit: "allow", + webfetch: "ask", + }) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "edit", pattern: "*", action: "allow" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + ]) +}) + +test("fromConfig - empty object", () => { + const result = PermissionNext.fromConfig({}) + expect(result).toEqual([]) +}) + +// merge tests + +test("merge - simple concatenation", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "*", action: "allow" }], + [{ permission: "bash", pattern: "*", action: "deny" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "deny" }, + ]) +}) + +test("merge - adds new permission", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "*", action: "allow" }], + [{ permission: "edit", pattern: "*", action: "deny" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + ]) +}) + +test("merge - concatenates rules for same permission", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "foo", action: "ask" }], + [{ permission: "bash", pattern: "*", action: "deny" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "foo", action: "ask" }, + { permission: "bash", pattern: "*", action: "deny" }, + ]) +}) + +test("merge - multiple rulesets", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "*", action: "allow" }], + [{ permission: "bash", pattern: "rm", action: "ask" }], + [{ permission: "edit", pattern: "*", action: "allow" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "ask" }, + { permission: "edit", pattern: "*", action: "allow" }, + ]) +}) + +test("merge - empty ruleset does nothing", () => { + const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) + expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) +}) + +test("merge - preserves rule order", () => { + const result = PermissionNext.merge( + [ + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/secret/*", action: "deny" }, + ], + [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }], + ) + expect(result).toEqual([ + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/secret/*", action: "deny" }, + { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }, + ]) +}) + +test("merge - config permission overrides default ask", () => { + // Simulates: defaults have "*": "ask", config sets bash: "allow" + const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] + const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const merged = PermissionNext.merge(defaults, config) + + // Config's bash allow should override default ask + expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow") + // Other permissions should still be ask (from defaults) + expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask") +}) + +test("merge - config ask overrides default allow", () => { + // Simulates: defaults have bash: "allow", config sets bash: "ask" + const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] + const merged = PermissionNext.merge(defaults, config) + + // Config's ask should override default allow + expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask") +}) + +// evaluate tests + +test("evaluate - exact pattern match", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard pattern match", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) + expect(result).toBe("allow") +}) + +test("evaluate - last matching rule wins", () => { + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - last matching rule wins (wildcard after specific)", () => { + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - glob pattern match", () => { + const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - last matching glob wins", () => { + const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + { permission: "edit", pattern: "src/*", action: "deny" }, + { permission: "edit", pattern: "src/components/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - order matters for specificity", () => { + // If more specific rule comes first, later wildcard overrides it + const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + { permission: "edit", pattern: "src/components/*", action: "allow" }, + { permission: "edit", pattern: "src/*", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - unknown permission returns ask", () => { + const result = PermissionNext.evaluate("unknown_tool", "anything", [ + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("ask") +}) + +test("evaluate - empty ruleset returns ask", () => { + const result = PermissionNext.evaluate("bash", "rm", []) + expect(result).toBe("ask") +}) + +test("evaluate - no matching pattern returns ask", () => { + const result = PermissionNext.evaluate("edit", "etc/passwd", [ + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("ask") +}) + +test("evaluate - empty rules array returns ask", () => { + const result = PermissionNext.evaluate("bash", "rm", []) + expect(result).toBe("ask") +}) + +test("evaluate - multiple matching patterns, last wins", () => { + const result = PermissionNext.evaluate("edit", "src/secret.ts", [ + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/secret.ts", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - non-matching patterns are skipped", () => { + const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "test/*", action: "deny" }, + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - exact match at end wins over earlier wildcard", () => { + const result = PermissionNext.evaluate("bash", "/bin/rm", [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "/bin/rm", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard at end overrides earlier exact match", () => { + const result = PermissionNext.evaluate("bash", "/bin/rm", [ + { permission: "bash", pattern: "/bin/rm", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +// wildcard permission tests + +test("evaluate - wildcard permission matches any permission", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard permission with specific pattern", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) + expect(result).toBe("deny") +}) + +test("evaluate - glob permission pattern", () => { + const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ + { permission: "mcp_*", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - specific permission and wildcard permission combined", () => { + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - wildcard permission does not match when specific exists", () => { + const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - multiple matching permission patterns combine rules", () => { + const result = PermissionNext.evaluate("mcp_dangerous", "anything", [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "mcp_*", pattern: "*", action: "allow" }, + { permission: "mcp_dangerous", pattern: "*", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard permission fallback for unknown tool", () => { + const result = PermissionNext.evaluate("unknown_tool", "anything", [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("ask") +}) + +test("evaluate - permission patterns sorted by length regardless of object order", () => { + // specific permission listed before wildcard, but specific should still win + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "*", pattern: "*", action: "deny" }, + ]) + // With flat list, last matching rule wins - so "*" matches bash and wins + expect(result).toBe("deny") +}) + +test("evaluate - merges multiple rulesets", () => { + const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] + // approved comes after config, so rm should be denied + const result = PermissionNext.evaluate("bash", "rm", config, approved) + expect(result).toBe("deny") +}) + +// disabled tests + +test("disabled - returns empty set when all tools allowed", () => { + const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) + expect(result.size).toBe(0) +}) + +test("disabled - disables tool when denied", () => { + const result = PermissionNext.disabled( + ["bash", "edit", "read"], + [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "deny" }, + ], + ) + expect(result.has("bash")).toBe(true) + expect(result.has("edit")).toBe(false) + expect(result.has("read")).toBe(false) +}) + +test("disabled - disables edit/write/patch/multiedit when edit denied", () => { + const result = PermissionNext.disabled( + ["edit", "write", "patch", "multiedit", "bash"], + [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + ], + ) + expect(result.has("edit")).toBe(true) + expect(result.has("write")).toBe(true) + expect(result.has("patch")).toBe(true) + expect(result.has("multiedit")).toBe(true) + expect(result.has("bash")).toBe(false) +}) + +test("disabled - does not disable when partially denied", () => { + const result = PermissionNext.disabled( + ["bash"], + [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + ) + expect(result.has("bash")).toBe(false) +}) + +test("disabled - does not disable when action is ask", () => { + const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) + expect(result.size).toBe(0) +}) + +test("disabled - disables when wildcard deny even with specific allow", () => { + // Tool is disabled because evaluate("bash", "*", ...) returns "deny" + // The "echo *" allow rule doesn't match the "*" pattern we're checking + const result = PermissionNext.disabled( + ["bash"], + [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "echo *", action: "allow" }, + ], + ) + expect(result.has("bash")).toBe(true) +}) + +test("disabled - does not disable when wildcard allow after deny", () => { + const result = PermissionNext.disabled( + ["bash"], + [ + { permission: "bash", pattern: "rm *", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ], + ) + expect(result.has("bash")).toBe(false) +}) + +test("disabled - disables multiple tools", () => { + const result = PermissionNext.disabled( + ["bash", "edit", "webfetch"], + [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "webfetch", pattern: "*", action: "deny" }, + ], + ) + expect(result.has("bash")).toBe(true) + expect(result.has("edit")).toBe(true) + expect(result.has("webfetch")).toBe(true) +}) + +test("disabled - wildcard permission denies all tools", () => { + const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) + expect(result.has("bash")).toBe(true) + expect(result.has("edit")).toBe(true) + expect(result.has("read")).toBe(true) +}) + +test("disabled - specific allow overrides wildcard deny", () => { + const result = PermissionNext.disabled( + ["bash", "edit", "read"], + [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ], + ) + expect(result.has("bash")).toBe(false) + expect(result.has("edit")).toBe(true) + expect(result.has("read")).toBe(true) +}) + +// ask tests + +test("ask - resolves immediately when action is allow", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + expect(result).toBeUndefined() + }, + }) +}) + +test("ask - throws RejectedError when action is deny", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["rm -rf /"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], + }), + ).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("ask - returns pending promise when action is ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const promise = PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }) + // Promise should be pending, not resolved + expect(promise).toBeInstanceOf(Promise) + // Don't await - just verify it returns a promise + }, + }) +}) + +// reply tests + +test("reply - once resolves the pending ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = PermissionNext.ask({ + id: "permission_test1", + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_test1", + reply: "once", + }) + + await expect(askPromise).resolves.toBeUndefined() + }, + }) +}) + +test("reply - reject throws RejectedError", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = PermissionNext.ask({ + id: "permission_test2", + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_test2", + reply: "reject", + }) + + await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("reply - always persists approval and resolves", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = PermissionNext.ask({ + id: "permission_test3", + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_test3", + reply: "always", + }) + + await expect(askPromise).resolves.toBeUndefined() + }, + }) + // Re-provide to reload state with stored permissions + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Stored approval should allow without asking + const result = await PermissionNext.ask({ + sessionID: "session_test2", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + expect(result).toBeUndefined() + }, + }) +}) + +test("reply - reject cancels all pending for same session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise1 = PermissionNext.ask({ + id: "permission_test4a", + sessionID: "session_same", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + const askPromise2 = PermissionNext.ask({ + id: "permission_test4b", + sessionID: "session_same", + permission: "edit", + patterns: ["foo.ts"], + metadata: {}, + always: [], + ruleset: [], + }) + + // Catch rejections before they become unhandled + const result1 = askPromise1.catch((e) => e) + const result2 = askPromise2.catch((e) => e) + + // Reject the first one + await PermissionNext.reply({ + requestID: "permission_test4a", + reply: "reject", + }) + + // Both should be rejected + expect(await result1).toBeInstanceOf(PermissionNext.RejectedError) + expect(await result2).toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("ask - checks all patterns and stops on first deny", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["echo hello", "rm -rf /"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }), + ).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("ask - allows all patterns when all match allow rules", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["echo hello", "ls -la", "pwd"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + expect(result).toBeUndefined() + }, + }) +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 9ef7dfb9d8..ee82813fb5 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" -import { Permission } from "../../src/permission" import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission/next" const ctx = { sessionID: "test", @@ -12,6 +12,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const projectRoot = path.join(__dirname, "../..") @@ -37,397 +38,164 @@ describe("tool.bash", () => { }) describe("tool.bash permissions", () => { - test("allows command matching allow pattern", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "echo *": "allow", - "*": "deny", - }, - }, - }), - ) - }, - }) + test("asks for bash permission with correct pattern", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const result = await bash.execute( + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + 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", - }, - }, - }), - ) - }, - }) - 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") - }, - }) - }) - - 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", - }, - }, - }), - ) - }, - }) - 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> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) }, - ctx, - ) - expect(result.metadata.exit).toBe(0) - - // pwd should be allowed - const pwd = await bash.execute( - { - command: "pwd", - description: "Print working directory", - }, - ctx, - ) - 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") - }, - }) - }) - - 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", - }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await BashTool.init() - // Basic find should work - const result = await bash.execute( - { - command: "find . -name '*.ts'", - description: "Find typescript files", - }, - ctx, - ) - 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") - }, - }) - }) - - 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", - }, - }, - }), - ) - }, - }) - 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", - }, - ctx, - ) - expect(status.metadata.exit).toBe(0) - - // git log should work - const log = await bash.execute( - { - command: "git log --oneline -5", - description: "Git log", - }, - ctx, - ) - 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") - }, - }) - }) - - 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", - }, - }, - }), - ) - }, - }) - 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( + } + await bash.execute( { command: "echo foo && echo bar", description: "Echo twice", }, - ctx, + testCtx, ) - expect(result.metadata.output).toContain("foo") - expect(result.metadata.output).toContain("bar") + 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") + }, + }) + }) - // 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") + 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() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) + + 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() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "ls", + workdir: "/tmp", + description: "List /tmp", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain("/tmp") + }, + }) + }) + + 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() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "git log --oneline -5", + description: "Git log", + }, + testCtx, + ) + expect(requests.length).toBe(1) + expect(requests[0].always.length).toBeGreaterThan(0) + expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true) + }, + }) + }) + + 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() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "cd .", + description: "Stay in current directory", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeUndefined() }, }) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index f3da666a09..a79d931575 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -11,6 +11,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const projectRoot = path.join(__dirname, "../..") diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts index 6d7d6db87f..3d3ec574e6 100644 --- a/packages/opencode/test/tool/patch.test.ts +++ b/packages/opencode/test/tool/patch.test.ts @@ -3,16 +3,17 @@ import path from "path" import { PatchTool } from "../../src/tool/patch" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { Permission } from "../../src/permission" +import { PermissionNext } from "../../src/permission/next" import * as fs from "fs/promises" const ctx = { sessionID: "test", messageID: "", - toolCallID: "", + callID: "", agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const patchTool = await PatchTool.init() @@ -59,7 +60,8 @@ describe("tool.patch", () => { patchTool.execute({ patchText: maliciousPatch }, ctx) // TODO: this sucks await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(Permission.pending()[ctx.sessionID]).toBeDefined() + const pending = await PermissionNext.list() + expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined() }, }) }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index eb860d04fc..1093a17fea 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -3,6 +3,7 @@ import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission/next" const ctx = { sessionID: "test", @@ -11,6 +12,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } describe("tool.read external_directory permission", () => { @@ -18,14 +20,6 @@ describe("tool.read external_directory permission", () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "test.txt"), "hello world") - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) }, }) await Instance.provide({ @@ -42,14 +36,6 @@ describe("tool.read external_directory permission", () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content") - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) }, }) await Instance.provide({ @@ -62,83 +48,74 @@ describe("tool.read external_directory permission", () => { }) }) - test("denies reading absolute path outside project directory", async () => { + test("asks for external_directory permission when reading absolute path outside project", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "secret.txt"), "secret data") }, }) - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) - }, - }) + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const read = await ReadTool.init() - await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow( - "not in the current working directory", - ) + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + 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> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + 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> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() }, }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b0610b64bc..f56e836779 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -55,8 +55,11 @@ import type { PartUpdateResponses, PathGetResponses, PermissionListResponses, + PermissionReplyErrors, + PermissionReplyResponses, PermissionRespondErrors, PermissionRespondResponses, + PermissionRuleset, ProjectCurrentResponses, ProjectListResponses, ProjectUpdateErrors, @@ -728,6 +731,7 @@ export class Session extends HeyApiClient { directory?: string parentID?: string title?: string + permission?: PermissionRuleset }, options?: Options, ) { @@ -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( 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( + parameters: { + requestID: string + directory?: string + reply?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "body", key: "reply" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * List pending permissions * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 85a3c42862..10764bebee 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -451,67 +451,32 @@ export type EventMessagePartRemoved = { } } -export type Permission = { +export type PermissionRequest = { id: string - type: string - pattern?: string | Array sessionID: string - messageID: string - callID?: string - title: string + permission: string + patterns: Array metadata: { [key: string]: unknown } - time: { - created: number + always: Array + 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 + 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 + } +} + 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 + 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 } +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" + | { + [key: string]: unknown + } | 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" - } + | PermissionConfig | undefined } @@ -1578,26 +1592,7 @@ export type Config = { */ instructions?: Array 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 + 200: Array } export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 973c217fd1..efa90d0b72 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,3 +1,4 @@ +<<<<<<< HEAD { "openapi": "3.1.1", "info": { @@ -9750,3 +9751,6 @@ } } } +======= +{} +>>>>>>> 4f732c838 (feat: add command-aware permission request system for granular tool approval) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index ac80dada73..d0e8afefd6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -455,8 +455,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const permission = createMemo(() => { const next = data.store.permission?.[props.message.sessionID]?.[0] - if (!next) return undefined - if (next.callID !== part.callID) return undefined + if (!next || !next.tool) return undefined + if (next.tool!.callID !== part.callID) return undefined return next }) @@ -732,19 +732,20 @@ ToolRegistry.register({ const childToolPart = createMemo(() => { const perm = childPermission() - if (!perm) return undefined + if (!perm || !perm.tool) return undefined const sessionId = childSessionId() if (!sessionId) return undefined // Find the tool part that matches the permission's callID const messages = data.store.message[sessionId] ?? [] - for (const msg of messages) { - const parts = data.store.part[msg.id] ?? [] - for (const part of parts) { - if (part.type === "tool" && (part as ToolPart).callID === perm.callID) { - return { part: part as ToolPart, message: msg } - } + const message = messages.findLast((m) => m.id === perm.tool!.messageID) + if (!message) return undefined + const parts = data.store.part[message.id] ?? [] + for (const part of parts) { + if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) { + return { part: part as ToolPart, message } } } + return undefined }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0ef1f135c7..8285b98229 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -2,7 +2,7 @@ import { AssistantMessage, Message as MessageType, Part as PartType, - type Permission, + type PermissionRequest, TextPart, ToolPart, } from "@opencode-ai/sdk/v2/client" @@ -132,7 +132,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyPermissions: Permission[] = [] + const emptyPermissions: PermissionRequest[] = [] const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = [] const idle = { type: "idle" as const } @@ -235,16 +235,18 @@ export function SessionTurn( if (props.stepsExpanded) return emptyPermissionParts const next = nextPermission() - if (!next) return emptyPermissionParts + if (!next || !next.tool) 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 }] - } + const message = assistantMessages().findLast((m) => m.id === next.tool!.messageID) + if (!message) return emptyPermissionParts + + const parts = data.store.part[message.id] ?? emptyParts + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (tool.callID === next.tool?.callID) return [{ part: tool, message }] } + return emptyPermissionParts }) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 3292ba579f..9f7ec813f9 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -14,7 +14,7 @@ type Data = { [sessionID: string]: PreloadMultiFileDiffResult[] } permission?: { - [sessionID: string]: Permission[] + [sessionID: string]: PermissionRequest[] } message: { [sessionID: string]: Message[]