diff --git a/package.json b/package.json index aa7031bec7..a30b55be73 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,8 @@ "url": "https://github.com/sst/opencode" }, "license": "MIT", + "randomField": "hello from claude", + "anotherRandomField": "xkcd-927-compliance-level", "prettier": { "semi": false, "printWidth": 120 diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 876570fd6c..4a3506e25f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -206,7 +206,7 @@ export const RunCommand = cmd({ const permission = event.properties if (permission.sessionID !== sessionID) continue const result = await select({ - message: `Permission required to run: ${permission.message}`, + message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, options: [ { value: "once", label: "Allow once" }, { value: "always", label: "Always allow: " + permission.always.join(", ") }, 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 d770931af3..9089d525da 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1390,7 +1390,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess }, get permission() { const permissions = sync.data.permission[props.message.sessionID] ?? [] - const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID) + const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID) return permissions[permissionIndex] }, get tool() { @@ -1483,12 +1483,13 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child const sync = useSync() const permission = createMemo(() => { - const callID = sync.data.permission[ctx.sessionID]?.at(0)?.callID + 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 }) @@ -1528,9 +1529,6 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child ~ {props.pending}} when={props.complete}> {props.icon} {props.children} - - · Permission required - {error()} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9bd4460280..34e7011144 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -8,6 +8,7 @@ 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 "" @@ -30,9 +31,8 @@ function EditBody(props: { request: PermissionRequest }) { const sync = useSync() const dimensions = useTerminalDimensions() - const metadata = props.request.metadata as { filepath?: string; diff?: string } - const filepath = createMemo(() => metadata.filepath ?? "") - const diff = createMemo(() => metadata.diff ?? "") + 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 @@ -44,7 +44,7 @@ function EditBody(props: { request: PermissionRequest }) { return ( - + {"→"} Edit {normalizePath(filepath())} @@ -75,32 +75,50 @@ function EditBody(props: { request: PermissionRequest }) { ) } -function TextBody(props: { text: string }) { +function TextBody(props: { title: string; description?: string; icon: string }) { const { theme } = useTheme() return ( - - - {"→"} - - {props.text} - + <> + + + {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 metadata = props.request.metadata as { filepath?: string } + 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 {} + }) return ( } + body={} options={{ confirm: "Confirm", cancel: "Cancel" }} onSelect={(option) => { if (option === "cancel") { @@ -114,27 +132,61 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { }} /> - - } - 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, - }) - }} - /> - } + body={ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} onSelect={(option) => { if (option === "always") { @@ -192,8 +244,8 @@ function Prompt>(props: { borderColor={theme.warning} customBorderChars={SplitBorder.customBorderChars} > - - + + {"△"} {props.title} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index fb551f9bef..54e3318dca 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -56,13 +56,17 @@ export namespace PermissionNext { export const Request = z .object({ id: Identifier.schema("permission"), - callID: z.string().optional(), sessionID: Identifier.schema("session"), permission: z.string(), patterns: z.string().array(), - message: z.string(), metadata: z.record(z.string(), z.any()), always: z.string().array(), + tool: z + .object({ + messageID: z.string(), + callID: z.string(), + }) + .optional(), }) .meta({ ref: "PermissionRequest", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6eb726a4d3..0558839445 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -157,7 +157,6 @@ export namespace SessionProcessor { await PermissionNext.ask({ permission: "doom_loop", patterns: [value.toolName], - message: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, sessionID: input.assistantMessage.sessionID, metadata: { tool: value.toolName, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4ead3f4ccf..2d4575a679 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -38,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" @@ -349,28 +351,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: taskAgent.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", { @@ -604,14 +613,14 @@ export namespace SessionPrompt { args, }, ) - const result = await item.execute(args, { + const ctx: Tool.Context = { 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) => { + metadata: async (val: { title?: string; metadata?: any }) => { const match = input.processor.partFromToolCall(options.toolCallId) if (match && match.state.status === "running") { await Session.updatePart({ @@ -628,7 +637,16 @@ export namespace SessionPrompt { }) } }, - }) + async ask(req) { + await PermissionNext.ask({ + ...req, + sessionID: input.sessionID, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: input.agent.permission, + }) + }, + } + const result = await item.execute(args, ctx) await Plugin.trigger( "tool.execute.after", { @@ -826,14 +844,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, @@ -885,16 +905,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/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6d942315b2..6671c939c4 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,13 +6,13 @@ 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 { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import { Shell } from "@/shell/shell" -import { PermissionNext } from "@/permission/next" + import { BashArity } from "@/permission/arity" const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 @@ -80,8 +80,6 @@ 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() @@ -134,29 +132,20 @@ export const BashTool = Tool.define("bash", async () => { } if (directories.size > 0) { - const dirs = Array.from(directories) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "external_directory", - message: `Requesting access to external directories: ${dirs.join(", ")}`, patterns: Array.from(directories), always: Array.from(directories).map((x) => x + "*"), - sessionID: ctx.sessionID, metadata: {}, - ruleset: agent.permission, }) } if (patterns.size > 0) { - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "bash", patterns: Array.from(patterns), always: Array.from(always), - sessionID: ctx.sessionID, - message: params.command, metadata: {}, - ruleset: agent.permission, }) } diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index c9259b2e85..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 { PermissionNext } from "@/permission/next" -import { Agent } from "@/agent/agent" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -52,20 +50,14 @@ export const CodeSearchTool = Tool.define("codesearch", { ), }), async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "codesearch", - message: "Search code for: " + params.query, patterns: [params.query], always: ["*"], - sessionID: ctx.sessionID, metadata: { query: params.query, tokensNum: params.tokensNum, }, - - ruleset: agent.permission, }) const codeRequest: McpCodeRequest = { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 90fcf69ed0..787282ecd0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -14,9 +14,7 @@ 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" -import { PermissionNext } from "@/permission/next" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -41,24 +39,17 @@ 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) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "external_directory", - message: `Edit file outside working directory: ${filePath}`, patterns: [parentDir, path.join(parentDir, "*")], always: [parentDir + "/*"], - sessionID: ctx.sessionID, metadata: { filepath: filePath, parentDir, }, - - ruleset: agent.permission, }) } @@ -69,19 +60,14 @@ export const EditTool = Tool.define("edit", { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "edit", - message: "Edit this file: " + path.relative(Instance.directory, filePath), patterns: [path.relative(Instance.worktree, filePath)], always: ["*"], - sessionID: ctx.sessionID, metadata: { filepath: filePath, diff, }, - - ruleset: agent.permission, }) await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { @@ -102,18 +88,14 @@ export const EditTool = Tool.define("edit", { diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) - await PermissionNext.ask({ + await ctx.ask({ permission: "edit", - callID: ctx.callID, - message: "Edit this file: " + path.relative(Instance.directory, filePath), patterns: [path.relative(Instance.worktree, filePath)], always: ["*"], - sessionID: ctx.sessionID, metadata: { filepath: filePath, diff, }, - ruleset: agent.permission, }) await file.write(contentNew) @@ -127,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() @@ -140,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 1075db6f25..0c643796de 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -4,8 +4,6 @@ import { Tool } from "./tool" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" -import { Agent } from "@/agent/agent" -import { PermissionNext } from "@/permission/next" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -19,20 +17,14 @@ export const GlobTool = Tool.define("glob", { ), }), async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "glob", - message: `Glob search: ${params.pattern}`, patterns: [params.pattern], always: ["*"], - sessionID: ctx.sessionID, metadata: { pattern: params.pattern, path: params.path, }, - - ruleset: agent.permission, }) let search = params.path ?? Instance.directory diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index c78fd83adb..4cbc5347f5 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -4,8 +4,6 @@ import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" -import { Agent } from "@/agent/agent" -import { PermissionNext } from "@/permission/next" const MAX_LINE_LENGTH = 2000 @@ -21,21 +19,15 @@ export const GrepTool = Tool.define("grep", { throw new Error("pattern is required") } - const agent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "grep", - message: `Grep search: ${params.pattern}`, patterns: [params.pattern], always: ["*"], - sessionID: ctx.sessionID, metadata: { pattern: params.pattern, path: params.path, include: params.include, }, - - ruleset: agent.permission, }) const searchPath = params.path || Instance.directory diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index a5494631c0..b8638b3e90 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -4,8 +4,6 @@ import * as path from "path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" -import { Agent } from "@/agent/agent" -import { PermissionNext } from "@/permission/next" export const IGNORE_PATTERNS = [ "node_modules/", @@ -45,19 +43,13 @@ export const ListTool = Tool.define("list", { async execute(params, ctx) { const searchPath = path.resolve(Instance.directory, params.path || ".") - const agent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "list", - message: `List directory: ${searchPath}`, patterns: [searchPath], always: ["*"], - sessionID: ctx.sessionID, metadata: { path: searchPath, }, - - ruleset: agent.permission, }) const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 05d1bd88fa..62d9f70f20 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -6,11 +6,9 @@ import { FileTime } from "../file/time" 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" -import { PermissionNext } from "@/permission/next" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -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,19 +52,14 @@ export const PatchTool = Tool.define("patch", { if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "external_directory", - message: `Patch file outside working directory: ${filePath}`, patterns: [parentDir, path.join(parentDir, "*")], always: [parentDir + "/*"], - sessionID: ctx.sessionID, metadata: { filepath: filePath, parentDir, }, - - ruleset: agent.permission, }) } @@ -141,18 +133,13 @@ export const PatchTool = Tool.define("patch", { } // Check permissions if needed - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "edit", - message: `Apply patch to ${fileChanges.length} files`, patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), always: ["*"], - sessionID: ctx.sessionID, metadata: { diff: totalDiff, }, - - ruleset: agent.permission, }) // Apply the changes diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 471e0cb5f2..847fe3ebe7 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,9 +8,7 @@ import DESCRIPTION from "./read.txt" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Identifier } from "../id/id" -import { Agent } from "@/agent/agent" import { iife } from "@/util/iife" -import { PermissionNext } from "@/permission/next" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -28,36 +26,25 @@ 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) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "external_directory", - message: `Access file outside working directory: ${filepath}`, patterns: [parentDir], always: [parentDir + "/*"], - sessionID: ctx.sessionID, metadata: { filepath, parentDir, }, - - ruleset: agent.permission, }) } - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "read", - message: `Read file ${filepath}`, patterns: [filepath], always: ["*"], - sessionID: ctx.sessionID, metadata: {}, - - ruleset: agent.permission, }) const block = iife(() => { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 2f9069797d..00a081eaca 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -2,9 +2,7 @@ import path from "path" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" -import { Agent } from "../agent/agent" import { ConfigMarkdown } from "../config/markdown" -import { PermissionNext } from "@/permission/next" export const SkillTool = Tool.define("skill", async () => { const skills = await Skill.all() @@ -46,8 +44,6 @@ export const SkillTool = Tool.define("skill", async () => { .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"), }), async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - const skill = await Skill.get(params.name) if (!skill) { @@ -55,15 +51,11 @@ export const SkillTool = Tool.define("skill", async () => { throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) } - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "skill", patterns: [params.name], always: [params.name], - sessionID: ctx.sessionID, - message: `Activate skill ${params.name}`, metadata: {}, - ruleset: agent.permission, }) // Load and parse skill content const parsed = await ConfigMarkdown.parse(skill.location) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index cd8f7bbe44..3bf800f112 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,7 +10,6 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" -import { PermissionNext } from "@/permission/next" export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) @@ -30,19 +29,14 @@ export const TaskTool = Tool.define("task", async () => { command: z.string().describe("The command that triggered this task").optional(), }), async execute(params, ctx) { - const callingAgent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "task", - message: `Launch task: ${params.description}`, patterns: [params.subagent_type], always: ["*"], - sessionID: ctx.sessionID, metadata: { description: params.description, subagent_type: params.subagent_type, }, - ruleset: callingAgent.permission, }) const agent = await Agent.get(params.subagent_type) 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 1c7c8ef729..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 { PermissionNext } from "@/permission/next" -import { Agent } from "@/agent/agent" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -25,21 +23,15 @@ export const WebFetchTool = Tool.define("webfetch", { throw new Error("URL must start with http:// or https://") } - const agent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "webfetch", - message: "Fetch content from: " + params.url, patterns: [params.url], always: ["*"], - sessionID: ctx.sessionID, metadata: { url: params.url, format: params.format, timeout: params.timeout, }, - - ruleset: agent.permission, }) 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 31fb8da19e..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 { PermissionNext } from "@/permission/next" -import { Agent } from "@/agent/agent" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -59,14 +57,10 @@ export const WebSearchTool = Tool.define("websearch", { .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), }), async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "websearch", - message: "Search web for: " + params.query, patterns: [params.query], always: ["*"], - sessionID: ctx.sessionID, metadata: { query: params.query, numResults: params.numResults, @@ -74,8 +68,6 @@ export const WebSearchTool = Tool.define("websearch", { type: params.type, contextMaxCharacters: params.contextMaxCharacters, }, - - ruleset: agent.permission, }) const searchRequest: McpSearchRequest = { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 29e04e7c78..2f35f38f16 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -8,8 +8,6 @@ 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 { PermissionNext } from "@/permission/next" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -21,37 +19,11 @@ 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`, - ) - } + ... } */ @@ -59,16 +31,11 @@ export const WriteTool = Tool.define("write", { const exists = await file.exists() if (exists) await FileTime.assert(ctx.sessionID, filepath) - await PermissionNext.ask({ - callID: ctx.callID, + await ctx.ask({ permission: "edit", - message: `Create new file ${path.relative(Instance.directory, filepath)}`, patterns: [path.relative(Instance.worktree, filepath)], always: ["*"], - sessionID: ctx.sessionID, metadata: {}, - - ruleset: agent.permission, }) await Bun.write(filepath, params.content) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index ed7c2b1304..31af4cd450 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -428,7 +428,6 @@ test("ask - resolves immediately when action is allow", async () => { sessionID: "session_test", permission: "bash", patterns: ["ls"], - message: "Run ls command", metadata: {}, always: [], ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], @@ -448,7 +447,6 @@ test("ask - throws RejectedError when action is deny", async () => { sessionID: "session_test", permission: "bash", patterns: ["rm -rf /"], - message: "Run dangerous command", metadata: {}, always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], @@ -467,7 +465,6 @@ test("ask - returns pending promise when action is ask", async () => { sessionID: "session_test", permission: "bash", patterns: ["ls"], - message: "Run ls command", metadata: {}, always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], @@ -491,7 +488,6 @@ test("reply - once resolves the pending ask", async () => { sessionID: "session_test", permission: "bash", patterns: ["ls"], - message: "Run ls command", metadata: {}, always: [], ruleset: [], @@ -517,7 +513,6 @@ test("reply - reject throws RejectedError", async () => { sessionID: "session_test", permission: "bash", patterns: ["ls"], - message: "Run ls command", metadata: {}, always: [], ruleset: [], @@ -543,7 +538,6 @@ test("reply - always persists approval and resolves", async () => { sessionID: "session_test", permission: "bash", patterns: ["ls"], - message: "Run ls command", metadata: {}, always: ["ls"], ruleset: [], @@ -566,7 +560,6 @@ test("reply - always persists approval and resolves", async () => { sessionID: "session_test2", permission: "bash", patterns: ["ls"], - message: "Run ls command", metadata: {}, always: [], ruleset: [], @@ -586,7 +579,6 @@ test("reply - reject cancels all pending for same session", async () => { sessionID: "session_same", permission: "bash", patterns: ["ls"], - message: "Run ls", metadata: {}, always: [], ruleset: [], @@ -597,7 +589,6 @@ test("reply - reject cancels all pending for same session", async () => { sessionID: "session_same", permission: "edit", patterns: ["foo.ts"], - message: "Edit file", metadata: {}, always: [], ruleset: [], @@ -630,7 +621,6 @@ test("ask - checks all patterns and stops on first deny", async () => { sessionID: "session_test", permission: "bash", patterns: ["echo hello", "rm -rf /"], - message: "Run commands", metadata: {}, always: [], ruleset: [ @@ -652,7 +642,6 @@ test("ask - allows all patterns when all match allow rules", async () => { sessionID: "session_test", permission: "bash", patterns: ["echo hello", "ls -la", "pwd"], - message: "Run safe commands", metadata: {}, always: [], ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 9ef7dfb9d8..31875d556b 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -12,6 +12,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const projectRoot = path.join(__dirname, "../..") 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..2a0df48f98 100644 --- a/packages/opencode/test/tool/patch.test.ts +++ b/packages/opencode/test/tool/patch.test.ts @@ -9,10 +9,11 @@ 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() diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index eb860d04fc..a808070a6d 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -11,6 +11,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } describe("tool.read external_directory permission", () => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e3dcfbff10..f0f8cf9a5f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -453,15 +453,17 @@ export type EventMessagePartRemoved = { export type PermissionRequest = { id: string - callID?: string sessionID: string permission: string patterns: Array - message: string metadata: { [key: string]: unknown } always: Array + tool?: { + messageID: string + callID: string + } } export type EventPermissionNextAsked = {