This commit is contained in:
Dax Raad
2025-12-28 19:01:42 -05:00
parent f24683e661
commit 8ca4343053
27 changed files with 206 additions and 276 deletions

View File

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

View File

@@ -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(", ") },

View File

@@ -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
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
<Show when={permission()}>
·<span style={{ fg: theme.warning }}> Permission required</span>
</Show>
</text>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>

View File

@@ -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 (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
@@ -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 (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted} flexShrink={0}>
{"→"}
</text>
<text fg={theme.textMuted}>{props.text}</text>
</box>
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted} flexShrink={0}>
{props.icon}
</text>
<text fg={theme.textMuted}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
</box>
</Show>
</>
)
}
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const sync = useSync()
const [store, setStore] = createStore({
always: false,
})
const 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 (
<Switch>
<Match when={store.always}>
<Prompt
title="Always allow"
body={<TextBody text={props.request.always.join("\n")} />}
body={<TextBody icon="→" title={props.request.always.join("\n")} />}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
if (option === "cancel") {
@@ -114,27 +132,61 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}}
/>
</Match>
<Match when={props.request.permission === "edit" && !store.always}>
<Prompt
title="Permission required"
body={<EditBody request={props.request} />}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={!store.always}>
<Prompt
title="Permission required"
body={<TextBody text={props.request.message} />}
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="◉"
title={
`${Locale.titlecase(input().subagent_type as string)} Task "` + (input().description ?? "") + `"`
}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
@@ -192,8 +244,8 @@ function Prompt<const T extends Record<string, string>>(props: {
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1}>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>

View File

@@ -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",

View File

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

View File

@@ -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"),

View File

@@ -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<string>()
if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
const patterns = new Set<string>()
@@ -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,
})
}

View File

@@ -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 = {

View File

@@ -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<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
}
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
return {
metadata: {
diagnostics,

View File

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

View File

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

View File

@@ -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}`) || [])

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission/next"
export namespace Tool {
interface Metadata {
@@ -19,6 +20,7 @@ export namespace Tool {
callID?: string
extra?: { [key: string]: any }
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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" }],

View File

@@ -12,6 +12,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
const projectRoot = path.join(__dirname, "../..")

View File

@@ -11,6 +11,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
const projectRoot = path.join(__dirname, "../..")

View File

@@ -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()

View File

@@ -11,6 +11,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
describe("tool.read external_directory permission", () => {

View File

@@ -453,15 +453,17 @@ export type EventMessagePartRemoved = {
export type PermissionRequest = {
id: string
callID?: string
sessionID: string
permission: string
patterns: Array<string>
message: string
metadata: {
[key: string]: unknown
}
always: Array<string>
tool?: {
messageID: string
callID: string
}
}
export type EventPermissionNextAsked = {