feat: add command-aware permission request system for granular tool approval

pull/6319/head
Dax Raad 2025-12-28 17:27:11 -05:00
parent dccb8875ad
commit f24683e661
44 changed files with 3208 additions and 1416 deletions

View File

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

View File

@ -5,6 +5,7 @@
// "url": "https://enterprise.dev.opencode.ai",
// },
"instructions": ["STYLE_GUIDE.md"],
"permission": "ask",
"provider": {
"opencode": {
"options": {},

View File

@ -80,7 +80,7 @@ export namespace ACP {
toolCall: {
toolCallId: permission.callID ?? permission.id,
status: "pending",
title: permission.title,
title: permission.message,
rawInput: permission.metadata,
kind: toToolKind(permission.type),
locations: toLocations(permission.type, permission.metadata),

View File

@ -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 } 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,72 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()
const defaultTools = cfg.tools ?? {}
const defaultPermission: Info["permission"] = {
edit: "allow",
bash: {
const permission: PermissionNext.Ruleset = PermissionNext.merge(
PermissionNext.fromConfig({
"*": "allow",
},
skill: {
"*": "allow",
},
webfetch: "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 ?? {},
doom_loop: "ask",
external_directory: "ask",
}),
PermissionNext.fromConfig(cfg.permission ?? {}),
)
const result: Record<string, Info> = {
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
permission,
mode: "primary",
native: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
permission: PermissionNext.merge(
permission,
PermissionNext.fromConfig({
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
},
}),
),
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(
permission,
PermissionNext.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
),
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(
permission,
PermissionNext.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
}),
),
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 +117,10 @@ export namespace Agent {
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
tools: {
"*": false,
},
permission: PermissionNext.fromConfig({
"*": "deny",
}),
options: {},
permission: agentPermission,
},
title: {
name: "title",
@ -181,9 +128,10 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: agentPermission,
permission: PermissionNext.fromConfig({
"*": "deny",
}),
prompt: PROMPT_TITLE,
tools: {},
},
summary: {
name: "summary",
@ -191,11 +139,13 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: agentPermission,
permission: PermissionNext.fromConfig({
"*": "deny",
}),
prompt: PROMPT_SUMMARY,
tools: {},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
@ -206,74 +156,22 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
permission: agentPermission,
permission,
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
})
@ -285,10 +183,8 @@ export namespace Agent {
return state().then((x) => Object.values(x))
}
export async function defaultAgent(): Promise<string> {
const agents = await state()
const defaultCandidate = Object.values(agents).find((a) => a.default)
return defaultCandidate?.name ?? "build"
export async function defaultAgent() {
return state().then((x) => Object.keys(x)[0])
}
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
@ -329,70 +225,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
}

View File

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

View File

@ -202,14 +202,14 @@ export const RunCommand = cmd({
break
}
if (event.type === "permission.updated") {
if (event.type === "permission.next.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required to run: ${permission.title}`,
message: `Permission required to run: ${permission.message}`,
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",

View File

@ -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"
@ -35,6 +34,7 @@ import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Permission } from "./component/dialog-permission"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@ -608,6 +608,7 @@ function App() {
}
}}
>
<Permission />
<Switch>
<Match when={route.data.type === "home"}>
<Home />

View File

@ -0,0 +1,53 @@
import { onMount } from "solid-js"
import { useDialog } from "../ui/dialog"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
export function Permission() {
const dialog = useDialog()
onMount(() => {})
return null
}
function DialogPermission() {
const dialog = useDialog()
const { theme } = useTheme()
onMount(() => {
dialog.setSize("medium")
})
return (
<box
gap={1}
paddingLeft={2}
paddingRight={2}
onKeyDown={(e) => {
console.log(e)
}}
ref={(r) => {
setTimeout(() => {
r?.focus()
}, 1)
}}
>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Permission Request</text>
<text fg={theme.textMuted}>esc</text>
</box>
<text fg={theme.textMuted}>Change to foo directory and create bar file</text>
<text>$ cd foo && touch bar</text>
<box paddingBottom={1}>
<box paddingLeft={2} paddingRight={2} backgroundColor={theme.primary}>
<text fg={theme.background}>Allow</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text>Always allow the touch command</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text>Reject</text>
</box>
</box>
</box>
)
}

View File

@ -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)
case "permission.next.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
@ -132,6 +112,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "permission.next.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

View File

@ -59,7 +59,7 @@ export function Footer() {
<Match when={connected()}>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,237 @@
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"
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 metadata = props.request.metadata as { filepath?: string; diff?: string }
const filepath = createMemo(() => metadata.filepath ?? "")
const diff = createMemo(() => metadata.diff ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(filepath()))
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box>
<diff
diff={diff()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</Show>
</box>
)
}
function TextBody(props: { text: 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>
)
}
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const [store, setStore] = createStore({
always: false,
})
const metadata = props.request.metadata as { filepath?: string }
return (
<Switch>
<Match when={store.always}>
<Prompt
title="Always allow"
body={<TextBody text={props.request.always.join("\n")} />}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
if (option === "cancel") {
setStore("always", false)
return
}
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</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} />}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
</Switch>
)
}
function Prompt<const T extends Record<string, string>>(props: {
title: string
body: JSX.Element
options: T
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
</box>
<box
flexDirection="row"
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent="space-between"
>
<box flexDirection="row" gap={1}>
<For each={keys}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
>
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
{props.options[option]}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
</box>
</box>
</box>
)
}

View File

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

View File

@ -368,7 +368,44 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Permission = z.enum(["ask", "allow", "deny"])
export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type PermissionAction = z.infer<typeof PermissionAction>
export const PermissionObject = z.record(z.string(), PermissionAction).meta({
ref: "PermissionObjectConfig",
})
export type PermissionObject = z.infer<typeof PermissionObject>
export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
ref: "PermissionRuleConfig",
})
export type PermissionRule = z.infer<typeof PermissionRule>
export const Permission = z
.object({
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction)
.transform((x) => (typeof x === "string" ? { "*": x } : x))
.meta({
ref: "PermissionConfig",
})
export type Permission = z.infer<typeof Permission>
export const Command = z.object({
@ -386,33 +423,70 @@ export namespace Config {
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
options: z.record(z.string(), z.any()).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.optional()
.describe("Hex color code for the agent (e.g., #FF5733)"),
maxSteps: z
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: Permission.optional(),
})
.catchall(z.any())
.transform((agent, ctx) => {
const knownKeys = new Set([
"model",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Extract unknown properties into options
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
// Convert legacy tools config to permissions
const permission: Permission = { ...agent.permission }
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
// write, edit, patch, multiedit all map to edit permission
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
} else {
permission[tool] = action
}
}
// Convert legacy maxSteps to steps
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: Permission
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
@ -785,16 +859,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({

View File

@ -0,0 +1,162 @@
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)
}
return tokens
}
/* Generated with following prompt:
You are generating a dictionary of command-prefix arities for bash-style commands.
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string number**, representing how many **tokens** define the command.
2. **Flags NEVER count as tokens**. Only subcommands count.
3. **Longest matching prefix wins**.
4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` `touch` (arity 1, explicitly listed)
* `git checkout main` `git checkout` (because `git` has arity 2)
* `npm install` `npm install` (because `npm` has arity 2)
* `npm run dev` `npm run dev` (because `npm run` has arity 3)
* `python script.py` `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
*/
const ARITY: Record<string, number> = {
cat: 1, // cat file.txt
cd: 1, // cd /path/to/dir
chmod: 1, // chmod 755 script.sh
chown: 1, // chown user:group file.txt
cp: 1, // cp source.txt dest.txt
echo: 1, // echo "hello world"
env: 1, // env
export: 1, // export PATH=/usr/bin
grep: 1, // grep pattern file.txt
kill: 1, // kill 1234
killall: 1, // killall process
ln: 1, // ln -s source target
ls: 1, // ls -la
mkdir: 1, // mkdir new-dir
mv: 1, // mv old.txt new.txt
ps: 1, // ps aux
pwd: 1, // pwd
rm: 1, // rm file.txt
rmdir: 1, // rmdir empty-dir
sleep: 1, // sleep 5
source: 1, // source ~/.bashrc
tail: 1, // tail -f log.txt
touch: 1, // touch file.txt
unset: 1, // unset VAR
which: 1, // which node
aws: 3, // aws s3 ls
az: 3, // az storage blob list
bazel: 2, // bazel build
brew: 2, // brew install node
bun: 2, // bun install
"bun run": 3, // bun run dev
"bun x": 3, // bun x vite
cargo: 2, // cargo build
"cargo add": 3, // cargo add tokio
"cargo run": 3, // cargo run main
cdk: 2, // cdk deploy
cf: 2, // cf push app
cmake: 2, // cmake build
composer: 2, // composer require laravel
consul: 2, // consul members
"consul kv": 3, // consul kv get config/app
crictl: 2, // crictl ps
deno: 2, // deno run server.ts
"deno task": 3, // deno task dev
doctl: 3, // doctl kubernetes cluster list
docker: 2, // docker run nginx
"docker builder": 3, // docker builder prune
"docker compose": 3, // docker compose up
"docker container": 3, // docker container ls
"docker image": 3, // docker image prune
"docker network": 3, // docker network inspect
"docker volume": 3, // docker volume ls
eksctl: 2, // eksctl get clusters
"eksctl create": 3, // eksctl create cluster
firebase: 2, // firebase deploy
flyctl: 2, // flyctl deploy
gcloud: 3, // gcloud compute instances list
gh: 3, // gh pr list
git: 2, // git checkout main
"git config": 3, // git config user.name
"git remote": 3, // git remote add origin
"git stash": 3, // git stash pop
go: 2, // go build
gradle: 2, // gradle build
helm: 2, // helm install mychart
heroku: 2, // heroku logs
hugo: 2, // hugo new site blog
ip: 2, // ip link show
"ip addr": 3, // ip addr show
"ip link": 3, // ip link set eth0 up
"ip netns": 3, // ip netns exec foo bash
"ip route": 3, // ip route add default via 1.1.1.1
kind: 2, // kind delete cluster
"kind create": 3, // kind create cluster
kubectl: 2, // kubectl get pods
"kubectl kustomize": 3, // kubectl kustomize overlays/dev
"kubectl rollout": 3, // kubectl rollout restart deploy/api
kustomize: 2, // kustomize build .
make: 2, // make build
mc: 2, // mc ls myminio
"mc admin": 3, // mc admin info myminio
minikube: 2, // minikube start
mongosh: 2, // mongosh test
mysql: 2, // mysql -u root
mvn: 2, // mvn compile
ng: 2, // ng generate component home
npm: 2, // npm install
"npm exec": 3, // npm exec vite
"npm init": 3, // npm init vue
"npm run": 3, // npm run dev
"npm view": 3, // npm view react version
nvm: 2, // nvm use 18
nx: 2, // nx build
openssl: 2, // openssl genrsa 2048
"openssl req": 3, // openssl req -new -key key.pem
"openssl x509": 3, // openssl x509 -in cert.pem
pip: 2, // pip install numpy
pipenv: 2, // pipenv install flask
pnpm: 2, // pnpm install
"pnpm dlx": 3, // pnpm dlx create-next-app
"pnpm exec": 3, // pnpm exec vite
"pnpm run": 3, // pnpm run dev
poetry: 2, // poetry add requests
podman: 2, // podman run alpine
"podman container": 3, // podman container ls
"podman image": 3, // podman image prune
psql: 2, // psql -d mydb
pulumi: 2, // pulumi up
"pulumi stack": 3, // pulumi stack output
pyenv: 2, // pyenv install 3.11
python: 2, // python -m venv env
rake: 2, // rake db:migrate
rbenv: 2, // rbenv install 3.2.0
"redis-cli": 2, // redis-cli ping
rustup: 2, // rustup update
serverless: 2, // serverless invoke
sfdx: 3, // sfdx force:org:list
skaffold: 2, // skaffold dev
sls: 2, // sls deploy
sst: 2, // sst deploy
swift: 2, // swift build
systemctl: 2, // systemctl restart nginx
terraform: 2, // terraform apply
"terraform workspace": 3, // terraform workspace select prod
tmux: 2, // tmux new -s dev
turbo: 2, // turbo run build
ufw: 2, // ufw allow 22
vault: 2, // vault login
"vault auth": 3, // vault auth list
"vault kv": 3, // vault kv get secret/api
vercel: 2, // vercel deploy
volta: 2, // volta install node
wp: 2, // wp plugin install
yarn: 2, // yarn add react
"yarn dlx": 3, // yarn dlx create-react-app
"yarn run": 3, // yarn run dev
}
}

View File

@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
import { Config } from "@/config/config"
export namespace Permission {
const log = Log.create({ service: "permission" })
@ -27,7 +28,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 +100,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 +124,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(),

View File

@ -0,0 +1,226 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import z from "zod"
export namespace PermissionNext {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({
permission: key,
action: value,
pattern: "*",
})
continue
}
ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export const Request = z
.object({
id: Identifier.schema("permission"),
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(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: z.string(),
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.next.asked", Request),
Replied: BusEvent.define(
"permission.next.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
reply: Reply,
}),
),
}
const state = Instance.state(async () => {
const projectID = Instance.project.id
const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
const pending: Record<
string,
{
info: Request
resolve: () => void
reject: (e: any) => void
}
> = {}
return {
pending,
approved: stored,
}
})
export const ask = fn(
Request.partial({ id: true }).extend({
ruleset: Ruleset,
}),
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const action = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action })
if (action === "deny") throw new RejectedError()
if (action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Asked, info)
})
}
if (action === "allow") continue
}
},
)
export const reply = fn(
z.object({
requestID: Identifier.schema("permission"),
reply: Reply,
}),
async (input) => {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) return
delete s.pending[input.requestID]
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
existing.reject(new RejectedError())
// Reject all other pending permissions for this session
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID === sessionID) {
delete s.pending[id]
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "reject",
})
pending.reject(new RejectedError())
}
}
return
}
if (input.reply === "once") {
existing.resolve()
return
}
if (input.reply === "always") {
const projectID = Instance.project.id
for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
await Storage.write(["permission", projectID], s.approved)
existing.resolve()
return
}
},
)
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Action {
const merged = merge(...rulesets)
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match?.action ?? "ask"
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
if (evaluate(permission, "*", ruleset) === "deny") {
result.add(tool)
}
}
return result
}
export class RejectedError extends Error {
constructor(public readonly reason?: string) {
super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
}
}
}

View File

@ -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"
@ -1558,6 +1559,41 @@ export namespace Server {
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)
},
)
.get(
"/permission",
describeRoute({

View File

@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
export namespace LLM {
const log = Log.create({ service: "llm" })
@ -200,13 +200,11 @@ export namespace LLM {
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const enabled = pipe(
input.agent.tools,
mergeDeep(await ToolRegistry.enabled(input.agent)),
mergeDeep(input.user.tools ?? {}),
)
for (const [key, value] of Object.entries(enabled)) {
if (value === false) delete input.tools[key]
const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
delete input.tools[tool]
}
}
return input.tools
}

View File

@ -14,6 +14,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 +153,19 @@ 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.mode)
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,
input: value.input,
},
always: [value.toolName],
ruleset: agent.permission,
})
}
}
break
@ -215,7 +203,11 @@ export namespace SessionProcessor {
status: "error",
input: value.input,
error: (value.error as any).toString(),
metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
metadata:
value.error instanceof Permission.RejectedError ||
value.error instanceof Permission.RejectedError
? value.error.metadata
: undefined,
time: {
start: match.state.time.start,
end: Date.now(),
@ -223,7 +215,10 @@ export namespace SessionProcessor {
},
})
if (value.error instanceof Permission.RejectedError) {
if (
value.error instanceof Permission.RejectedError ||
value.error instanceof PermissionNext.RejectedError
) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]

View File

@ -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"
@ -473,7 +472,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,
@ -587,13 +586,7 @@ export namespace SessionPrompt {
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const enabledTools = pipe(
input.agent.tools,
mergeDeep(await ToolRegistry.enabled(input.agent)),
mergeDeep(input.tools ?? {}),
)
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
if (Wildcard.all(item.id, enabledTools) === false) continue
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,
@ -656,7 +649,6 @@ 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

View File

@ -44,7 +44,7 @@ export namespace SystemPrompt {
`</env>`,
`<files>`,
` ${
project.vcs === "git"
project.vcs === "git" && false
? await Ripgrep.tree({
cwd: Instance.directory,
limit: 200,

View File

@ -9,12 +9,11 @@ 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 { PermissionNext } from "@/permission/next"
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
@ -83,39 +82,11 @@ export const BashTool = Tool.define("bash", async () => {
}
const agent = await Agent.get(ctx.agent)
const checkExternalDirectory = async (dir: string) => {
if (Filesystem.contains(Instance.directory, dir)) return
const title = `This command references paths outside of ${Instance.directory}`
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [dir, path.join(dir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title,
metadata: {
command: params.command,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
command: params.command,
},
`${title} so this command is not allowed to be executed.`,
)
}
}
const directories = new Set<string>()
if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
await checkExternalDirectory(cwd)
const permissions = agent.permission.bash
const askPatterns = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
const command = []
@ -150,48 +121,42 @@ 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,
if (directories.size > 0) {
const dirs = Array.from(directories)
await PermissionNext.ask({
callID: ctx.callID,
title: params.command,
metadata: {
command: params.command,
patterns,
},
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,
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,8 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./codesearch.txt"
import { Config } from "../config/config"
import { Permission } from "../permission"
import { PermissionNext } from "@/permission/next"
import { Agent } from "@/agent/agent"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@ -52,19 +52,21 @@ 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,
},
})
const agent = await Agent.get(ctx.agent)
await PermissionNext.ask({
callID: ctx.callID,
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 = {
jsonrpc: "2.0",

View File

@ -8,7 +8,6 @@ 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"
@ -17,6 +16,7 @@ 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
@ -46,31 +46,20 @@ export const EditTool = Tool.define("edit", {
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 PermissionNext.ask({
callID: ctx.callID,
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,
})
}
let diff = ""
@ -80,19 +69,20 @@ 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 PermissionNext.ask({
callID: ctx.callID,
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, {
file: filePath,
@ -112,19 +102,19 @@ 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 PermissionNext.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)
await Bus.publish(File.Event.Edited, {

View File

@ -4,6 +4,8 @@ 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,
@ -16,7 +18,23 @@ 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) {
const agent = await Agent.get(ctx.agent)
await PermissionNext.ask({
callID: ctx.callID,
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
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)

View File

@ -4,6 +4,8 @@ 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
@ -14,11 +16,28 @@ 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")
}
const agent = await Agent.get(ctx.agent)
await PermissionNext.ask({
callID: ctx.callID,
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
const rgPath = await Ripgrep.filepath()

View File

@ -4,6 +4,8 @@ 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/",
@ -40,9 +42,24 @@ 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 || ".")
const agent = await Agent.get(ctx.agent)
await PermissionNext.ask({
callID: ctx.callID,
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}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) {

View File

@ -3,7 +3,6 @@ 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"
@ -11,6 +10,7 @@ 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"),
@ -55,31 +55,20 @@ 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 PermissionNext.ask({
callID: ctx.callID,
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,
})
}
switch (hunk.type) {
@ -152,18 +141,19 @@ 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 PermissionNext.ask({
callID: ctx.callID,
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
const changedFiles: string[] = []

View File

@ -8,9 +8,9 @@ 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"
import { PermissionNext } from "@/permission/next"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@ -32,33 +32,34 @@ export const ReadTool = Tool.define("read", {
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 PermissionNext.ask({
callID: ctx.callID,
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,
permission: "read",
message: `Read file ${filepath}`,
patterns: [filepath],
always: ["*"],
sessionID: ctx.sessionID,
metadata: {},
ruleset: agent.permission,
})
const block = iife(() => {
const basename = path.basename(filepath)
const whitelist = [".env.sample", ".env.example", ".example", ".env.template"]

View File

@ -135,27 +135,4 @@ export namespace ToolRegistry {
)
return result
}
export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {}
if (agent.permission.edit === "deny") {
result["edit"] = false
result["write"] = false
}
if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
result["bash"] = false
}
if (agent.permission.webfetch === "deny") {
result["webfetch"] = false
result["codesearch"] = false
result["websearch"] = false
}
// Disable skill tool if all skills are denied
if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
result["skill"] = false
}
return result
}
}

View File

@ -3,20 +3,14 @@ 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"
import { PermissionNext } from "@/permission/next"
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<typeof parameters> = {
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 +19,67 @@ export const SkillTool: Tool.Info<typeof parameters> = {
return action !== "deny"
})
}
*/
const description =
accessibleSkills.length === 0
? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"<available_skills>",
...accessibleSkills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` </skill>`,
]),
"</available_skills>",
].join(" ")
const description =
skills.length === 0
? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"<available_skills>",
...skills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` </skill>`,
]),
"</available_skills>",
].join(" ")
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 agent = await Agent.get(ctx.agent)
const skill = await Skill.get(params.name)
const skill = await Skill.get(params.name)
if (!skill) {
const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
if (!skill) {
const available = Skill.all().then((x) => Object.keys(x).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
// Check permission using Wildcard.all on the skill name
const permissions = agent.permission.skill
const action = Wildcard.all(params.name, permissions)
await PermissionNext.ask({
callID: ctx.callID,
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)
const dir = path.dirname(skill.location)
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}".`,
)
}
// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
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,
},
}
},
}
})

View File

@ -10,6 +10,7 @@ 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"))
@ -29,6 +30,21 @@ 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,
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)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const session = await iife(async () => {

View File

@ -2,8 +2,8 @@ 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"
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,20 +25,22 @@ 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,
},
})
const agent = await Agent.get(ctx.agent)
await PermissionNext.ask({
callID: ctx.callID,
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,8 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./websearch.txt"
import { Config } from "../config/config"
import { Permission } from "../permission"
import { PermissionNext } from "@/permission/next"
import { Agent } from "@/agent/agent"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@ -59,22 +59,24 @@ 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,
},
})
const agent = await Agent.get(ctx.agent)
await PermissionNext.ask({
callID: ctx.callID,
permission: "websearch",
message: "Search web for: " + params.query,
patterns: [params.query],
always: ["*"],
sessionID: ctx.sessionID,
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
ruleset: agent.permission,
})
const searchRequest: McpSearchRequest = {
jsonrpc: "2.0",

View File

@ -2,7 +2,6 @@ import z from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { Bus } from "../bus"
import { File } from "../file"
@ -10,6 +9,7 @@ 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
@ -24,6 +24,7 @@ export const WriteTool = Tool.define("write", {
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") {
@ -52,24 +53,23 @@ export const WriteTool = Tool.define("write", {
)
}
}
*/
const file = Bun.file(filepath)
const exists = await file.exists()
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,
},
})
await PermissionNext.ask({
callID: ctx.callID,
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)
await Bus.publish(File.Event.Edited, {

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { $ } from "bun"
import * as fs from "fs/promises"
import os from "os"
import path from "path"
import type { Config } from "../../src/config/config"
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@ -10,6 +11,7 @@ function sanitizePath(p: string): string {
type TmpDirOptions<T> = {
git?: boolean
config?: Partial<Config.Info>
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
@ -20,6 +22,15 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await $`git init`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
if (options?.config) {
await Bun.write(
path.join(dirpath, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
...options.config,
}),
)
}
const extra = await options?.init?.(dirpath)
const realpath = sanitizePath(await fs.realpath(dirpath))
const result = {

View File

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

View File

@ -0,0 +1,663 @@
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"],
message: "Run ls command",
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 /"],
message: "Run dangerous command",
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"],
message: "Run ls command",
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"],
message: "Run ls command",
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"],
message: "Run ls command",
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"],
message: "Run ls command",
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"],
message: "Run ls command",
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"],
message: "Run ls",
metadata: {},
always: [],
ruleset: [],
})
const askPromise2 = PermissionNext.ask({
id: "permission_test4b",
sessionID: "session_same",
permission: "edit",
patterns: ["foo.ts"],
message: "Edit file",
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 /"],
message: "Run commands",
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"],
message: "Run safe commands",
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
},
})
})

View File

@ -55,6 +55,8 @@ import type {
PartUpdateResponses,
PathGetResponses,
PermissionListResponses,
PermissionReplyErrors,
PermissionReplyResponses,
PermissionRespondErrors,
PermissionRespondResponses,
ProjectCurrentResponses,
@ -1626,6 +1628,43 @@ export class Permission extends HeyApiClient {
})
}
/**
* Respond to permission request
*
* Approve or deny a permission request from the AI assistant.
*/
public reply<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
reply?: "once" | "always" | "reject"
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "body", key: "reply" },
],
},
],
)
return (options?.client ?? this.client).post<PermissionReplyResponses, PermissionReplyErrors, ThrowOnError>({
url: "/permission/{requestID}/reply",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* List pending permissions
*

View File

@ -451,6 +451,33 @@ 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>
}
export type EventPermissionNextAsked = {
type: "permission.next.asked"
properties: PermissionRequest
}
export type EventPermissionNextReplied = {
type: "permission.next.replied"
properties: {
sessionID: string
requestID: string
reply: "once" | "always" | "reject"
}
}
export type Permission = {
id: string
type: string
@ -458,7 +485,7 @@ export type Permission = {
sessionID: string
messageID: string
callID?: string
title: string
message: string
metadata: {
[key: string]: unknown
}
@ -481,40 +508,6 @@ export type EventPermissionReplied = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type SessionStatus =
| {
type: "idle"
@ -551,6 +544,40 @@ export type EventSessionCompacted = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@ -756,13 +783,15 @@ export type Event =
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventPermissionNextAsked
| EventPermissionNextReplied
| EventPermissionUpdated
| EventPermissionReplied
| EventFileEdited
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventFileEdited
| EventTodoUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@ -1183,11 +1212,42 @@ export type ServerConfig = {
cors?: Array<string>
}
export type PermissionActionConfig = "ask" | "allow" | "deny"
export type PermissionObjectConfig = {
[key: string]: PermissionActionConfig
}
export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig
export type PermissionConfig =
| {
read?: PermissionRuleConfig
edit?: PermissionRuleConfig
glob?: PermissionRuleConfig
grep?: PermissionRuleConfig
list?: PermissionRuleConfig
bash?: PermissionRuleConfig
task?: PermissionRuleConfig
external_directory?: PermissionRuleConfig
todowrite?: PermissionActionConfig
todoread?: PermissionActionConfig
webfetch?: PermissionActionConfig
websearch?: PermissionActionConfig
codesearch?: PermissionActionConfig
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 +1257,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 +1267,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 +1284,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 +1610,7 @@ export type Config = {
*/
instructions?: Array<string>
layout?: LayoutConfig
permission?: {
edit?: "ask" | "allow" | "deny"
bash?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
skill?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: "ask" | "allow" | "deny"
doom_loop?: "ask" | "allow" | "deny"
external_directory?: "ask" | "allow" | "deny"
}
permission?: PermissionConfig
tools?: {
[key: string]: boolean
}
@ -1880,40 +1893,35 @@ export type File = {
status: "added" | "deleted" | "modified"
}
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRule = {
permission: string
pattern: string
action: PermissionAction
}
export type PermissionRuleset = Array<PermissionRule>
export type Agent = {
name: string
description?: string
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 = {
@ -3391,6 +3399,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

View File

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