Permission rework (#6319)

Co-authored-by: Github Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Dax
2026-01-01 17:54:11 -05:00
committed by GitHub
parent dccb8875ad
commit 351ddeed91
66 changed files with 3658 additions and 2146 deletions

View File

@@ -2,11 +2,9 @@ name: test
on:
push:
branches-ignore:
- production
branches:
- dev
pull_request:
branches-ignore:
- production
workflow_dispatch:
jobs:
test:

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

@@ -10,7 +10,13 @@
"options": {},
},
},
"mcp": {},
"permission": "ask",
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"tools": {
"github-triage": false,
},

View File

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

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767151656,
"narHash": "sha256-ujL2AoYBnJBN262HD95yer7QYUmYp5kFZGYbyCCKxq8=",
"lastModified": 1767242400,
"narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f665af0cdb70ed27e1bd8f9fdfecaf451260fc55",
"rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a",
"type": "github"
},
"original": {

View File

@@ -10,7 +10,8 @@
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
"hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [

View File

@@ -15,7 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
type Permission,
type PermissionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -46,7 +46,7 @@ type State = {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: Permission[]
[sessionID: string]: PermissionRequest[]
}
mcp: {
[name: string]: McpStatus
@@ -168,7 +168,7 @@ function createGlobalSync() {
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
permission: () =>
sdk.permission.list().then((x) => {
const grouped: Record<string, Permission[]> = {}
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
@@ -349,7 +349,7 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch })
break
}
case "permission.updated": {
case "permission.asked": {
const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID]
if (!permissions) {
@@ -375,7 +375,7 @@ function createGlobalSync() {
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) break
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
if (!result.found) break
setStore(
"permission",

View File

@@ -1,7 +1,7 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
@@ -14,10 +14,8 @@ type PermissionRespondFn = (input: {
directory?: string
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
function shouldAutoAccept(perm: Permission) {
return AUTO_ACCEPT_TYPES.has(perm.type)
function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit"
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
@@ -48,7 +46,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
function respondOnce(permission: Permission, directory?: string) {
function respondOnce(permission: PermissionRequest, directory?: string) {
if (responded.has(permission.id)) return
responded.add(permission.id)
respond({
@@ -65,7 +63,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.updated") return
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return
@@ -98,7 +96,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return {
ready,
respond,
autoResponds(permission: Permission) {
autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
},
isAutoAccepting,

View File

@@ -175,7 +175,7 @@ export default function Layout(props: ParentProps) {
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
if (e.details?.type !== "permission.asked") return
const directory = e.name
const perm = e.details.properties
if (permission.autoResponds(perm)) return

View File

@@ -71,19 +71,19 @@ export namespace ACP {
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
case "permission.updated":
case "permission.asked":
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId,
toolCall: {
toolCallId: permission.callID ?? permission.id,
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.title,
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.type),
locations: toLocations(permission.type, permission.metadata),
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options,
})
@@ -93,28 +93,25 @@ export namespace ACP {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.config.sdk.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.config.sdk.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
}
await this.config.sdk.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: res.outcome.optionId as "once" | "always" | "reject",
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
directory,
})
} catch (err) {

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, pipe, sortBy, values } from "remeda"
export namespace Agent {
export const Info = z
@@ -23,18 +21,10 @@ export namespace Agent {
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
default: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: z.object({
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
skill: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
doom_loop: Config.Permission.optional(),
external_directory: Config.Permission.optional(),
}),
permission: PermissionNext.Ruleset,
model: z
.object({
modelID: z.string(),
@@ -42,9 +32,8 @@ export namespace Agent {
})
.optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()),
options: z.record(z.string(), z.any()),
maxSteps: z.number().int().positive().optional(),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
@@ -53,113 +42,74 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()
const defaultTools = cfg.tools ?? {}
const defaultPermission: Info["permission"] = {
edit: "allow",
bash: {
"*": "allow",
},
skill: {
"*": "allow",
},
webfetch: "allow",
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: "ask",
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
const planPermission = mergeAgentPermissions(
{
edit: "deny",
bash: {
"cut*": "allow",
"diff*": "allow",
"du*": "allow",
"file *": "allow",
"find * -delete*": "ask",
"find * -exec*": "ask",
"find * -fprint*": "ask",
"find * -fls*": "ask",
"find * -fprintf*": "ask",
"find * -ok*": "ask",
"find *": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"git status*": "allow",
"git branch": "allow",
"git branch -v": "allow",
"grep*": "allow",
"head*": "allow",
"less*": "allow",
"ls*": "allow",
"more*": "allow",
"pwd*": "allow",
"rg*": "allow",
"sort --output=*": "ask",
"sort -o *": "ask",
"sort*": "allow",
"stat*": "allow",
"tail*": "allow",
"tree -o *": "ask",
"tree*": "allow",
"uniq*": "allow",
"wc*": "allow",
"whereis*": "allow",
"which*": "allow",
"*": "ask",
},
webfetch: "allow",
},
cfg.permission ?? {},
)
})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = {
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
permission: PermissionNext.merge(defaults, user),
mode: "primary",
native: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
tools: {
todoread: false,
todowrite: false,
...defaultTools,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
permission: agentPermission,
mode: "subagent",
native: true,
hidden: true,
},
explore: {
name: "explore",
tools: {
todoread: false,
todowrite: false,
edit: false,
write: false,
...defaultTools,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
permission: agentPermission,
mode: "subagent",
native: true,
},
@@ -169,11 +119,14 @@ export namespace Agent {
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
tools: {
"*": false,
},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
options: {},
permission: agentPermission,
},
title: {
name: "title",
@@ -181,9 +134,14 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: agentPermission,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
tools: {},
},
summary: {
name: "summary",
@@ -191,11 +149,17 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
permission: agentPermission,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
tools: {},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
@@ -206,74 +170,22 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
permission: agentPermission,
permission: PermissionNext.merge(defaults, user),
options: {},
tools: {},
native: false,
}
const {
name,
model,
prompt,
tools,
description,
temperature,
top_p,
mode,
permission,
color,
maxSteps,
...extra
} = value
item.options = {
...item.options,
...extra,
}
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
item.tools = {
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
}
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name
if (maxSteps != undefined) item.maxSteps = maxSteps
if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}
if (value.model) item.model = Provider.parseModel(value.model)
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.name = value.options?.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
// Mark the default agent
const defaultName = cfg.default_agent ?? "build"
const defaultCandidate = result[defaultName]
if (defaultCandidate && defaultCandidate.mode !== "subagent") {
defaultCandidate.default = true
} else {
// Fall back to "build" if configured default is invalid
if (result["build"]) {
result["build"].default = true
}
}
const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
if (!hasPrimaryAgents) {
throw new Config.InvalidError({
path: "config",
message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
})
}
return result
})
@@ -282,13 +194,16 @@ export namespace Agent {
}
export async function list() {
return state().then((x) => Object.values(x))
const cfg = await Config.get()
return pipe(
await state(),
values(),
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
)
}
export async function defaultAgent(): Promise<string> {
const agents = await state()
const defaultCandidate = Object.values(agents).find((a) => a.default)
return defaultCandidate?.name ?? "build"
export async function defaultAgent() {
return state().then((x) => Object.keys(x)[0])
}
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
@@ -329,70 +244,3 @@ export namespace Agent {
return result.object
}
}
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
if (typeof basePermission.bash === "string") {
basePermission.bash = {
"*": basePermission.bash,
}
}
if (typeof overridePermission.bash === "string") {
overridePermission.bash = {
"*": overridePermission.bash,
}
}
if (typeof basePermission.skill === "string") {
basePermission.skill = {
"*": basePermission.skill,
}
}
if (typeof overridePermission.skill === "string") {
overridePermission.skill = {
"*": overridePermission.skill,
}
}
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
let mergedBash
if (merged.bash) {
if (typeof merged.bash === "string") {
mergedBash = {
"*": merged.bash,
}
} else if (typeof merged.bash === "object") {
mergedBash = mergeDeep(
{
"*": "allow",
},
merged.bash,
)
}
}
let mergedSkill
if (merged.skill) {
if (typeof merged.skill === "string") {
mergedSkill = {
"*": merged.skill,
}
} else if (typeof merged.skill === "object") {
mergedSkill = mergeDeep(
{
"*": "allow",
},
merged.skill,
)
}
}
const result: Agent.Info["permission"] = {
edit: merged.edit ?? "allow",
webfetch: merged.webfetch ?? "allow",
bash: mergedBash ?? { "*": "allow" },
skill: mergedSkill ?? { "*": "allow" },
doom_loop: merged.doom_loop,
external_directory: merged.external_directory,
}
return result
}

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

@@ -1,9 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { ToolRegistry } from "../../../tool/registry"
import { Wildcard } from "../../../util/wildcard"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -25,27 +22,7 @@ export const AgentCommand = cmd({
)
process.exit(1)
}
const resolvedTools = await resolveTools(agent)
const output = {
...agent,
tools: resolvedTools,
toolOverrides: agent.tools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
})
},
})
async function resolveTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
const toolOverrides = {
...agent.tools,
...(await ToolRegistry.enabled(agent)),
}
const availableTools = await ToolRegistry.tools(providerID, agent)
const resolved: Record<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
}
return resolved
}

View File

@@ -202,14 +202,14 @@ export const RunCommand = cmd({
break
}
if (event.type === "permission.updated") {
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required to run: ${permission.title}`,
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
options: [
{ value: "once", label: "Allow once" },
{ value: "always", label: "Always allow" },
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
{ value: "reject", label: "Reject" },
],
initialValue: "once",

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"

View File

@@ -33,6 +33,7 @@ import { useKV } from "../../context/kv"
export type PromptProps = {
sessionID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
ref?: (ref: PromptRef) => void
@@ -373,7 +374,8 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
input.focus()
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
})
onMount(() => {
@@ -798,7 +800,7 @@ export function Prompt(props: PromptProps) {
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)}>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}

View File

@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [

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)
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
@@ -132,6 +112,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break

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,313 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale"
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
}
return input
}
function filetype(input?: string) {
if (!input) return "none"
const ext = path.extname(input)
const language = LANGUAGE_EXTENSIONS[ext]
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
return language
}
function EditBody(props: { request: PermissionRequest }) {
const { theme, syntax } = useTheme()
const sync = useSync()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(filepath()))
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
<diff
diff={diff()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</Show>
</box>
)
}
function TextBody(props: { title: string; description?: string; icon?: string }) {
const { theme } = useTheme()
return (
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<Show when={props.icon}>
<text fg={theme.textMuted} flexShrink={0}>
{props.icon}
</text>
</Show>
<text fg={theme.textMuted}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
</box>
</Show>
</>
)
}
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const sync = useSync()
const [store, setStore] = createStore({
always: false,
})
const input = createMemo(() => {
const tool = props.request.tool
if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? []
for (const part of parts) {
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
return part.state.input ?? {}
}
}
return {}
})
const { theme } = useTheme()
return (
<Switch>
<Match when={store.always}>
<Prompt
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
{pattern}
</text>
)}
</For>
</box>
</box>
</Match>
</Switch>
}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
setStore("always", false)
if (option === "cancel") return
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={!store.always}>
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
</Switch>
)
}
function Prompt<const T extends Record<string, string>>(props: {
title: string
body: JSX.Element
options: T
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent="space-between"
>
<box flexDirection="row" gap={1}>
<For each={keys}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
>
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
{props.options[option]}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
</box>
</box>
</box>
)
}

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

@@ -123,13 +123,22 @@ export namespace Config {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (!result.username) result.username = os.userInfo().username
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
// Backwards compatibility: legacy top-level `tools` config
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
@@ -368,7 +377,45 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Permission = z.enum(["ask", "allow", "deny"])
export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type PermissionAction = z.infer<typeof PermissionAction>
export const PermissionObject = z.record(z.string(), PermissionAction).meta({
ref: "PermissionObjectConfig",
})
export type PermissionObject = z.infer<typeof PermissionObject>
export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
ref: "PermissionRuleConfig",
})
export type PermissionRule = z.infer<typeof PermissionRule>
export const Permission = z
.object({
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction)
.transform((x) => (typeof x === "string" ? { "*": x } : x))
.meta({
ref: "PermissionConfig",
})
export type Permission = z.infer<typeof Permission>
export const Command = z.object({
@@ -386,33 +433,70 @@ export namespace Config {
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
options: z.record(z.string(), z.any()).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.optional()
.describe("Hex color code for the agent (e.g., #FF5733)"),
maxSteps: z
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: Permission.optional(),
})
.catchall(z.any())
.transform((agent, ctx) => {
const knownKeys = new Set([
"model",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Extract unknown properties into options
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
// Convert legacy tools config to permissions
const permission: Permission = { ...agent.permission }
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
// write, edit, patch, multiedit all map to edit permission
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
} else {
permission[tool] = action
}
}
// Convert legacy maxSteps to steps
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: Permission
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
@@ -785,16 +869,7 @@ export namespace Config {
),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
permission: Permission.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({

View File

@@ -158,6 +158,7 @@ export namespace Installation {
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"

View File

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

View File

@@ -27,7 +27,7 @@ export namespace Permission {
sessionID: z.string(),
messageID: z.string(),
callID: z.string().optional(),
title: z.string(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
@@ -99,7 +99,7 @@ export namespace Permission {
export async function ask(input: {
type: Info["type"]
title: Info["title"]
message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
@@ -123,7 +123,7 @@ export namespace Permission {
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
title: input.title,
message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),

View File

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

View File

@@ -78,6 +78,7 @@ export namespace Plugin {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
for (const hook of hooks) {
// @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config)
}
Bus.subscribeAll(async (input) => {

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"
@@ -1524,6 +1525,7 @@ export namespace Server {
"/session/:sessionID/permissions/:permissionID",
describeRoute({
summary: "Respond to permission",
deprecated: true,
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.respond",
responses: {
@@ -1545,15 +1547,47 @@ export namespace Server {
permissionID: z.string(),
}),
),
validator("json", z.object({ response: Permission.Response })),
validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
const sessionID = params.sessionID
const permissionID = params.permissionID
Permission.respond({
sessionID,
permissionID,
response: c.req.valid("json").response,
PermissionNext.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
return c.json(true)
},
)
.post(
"/permission/:requestID/reply",
describeRoute({
summary: "Respond to permission request",
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.reply",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", z.object({ reply: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
})
return c.json(true)
},
@@ -1569,14 +1603,14 @@ export namespace Server {
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(Permission.Info.array()),
schema: resolver(PermissionNext.Request.array()),
},
},
},
},
}),
async (c) => {
const permissions = Permission.list()
const permissions = await PermissionNext.list()
return c.json(permissions)
},
)

View File

@@ -18,6 +18,7 @@ import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -62,6 +63,7 @@ export namespace Session {
compacting: z.number().optional(),
archived: z.number().optional(),
}),
permission: PermissionNext.Ruleset.optional(),
revert: z
.object({
messageID: z.string(),
@@ -126,6 +128,7 @@ export namespace Session {
.object({
parentID: Identifier.schema("session").optional(),
title: z.string().optional(),
permission: Info.shape.permission,
})
.optional(),
async (input) => {
@@ -133,6 +136,7 @@ export namespace Session {
parentID: input?.parentID,
directory: Instance.directory,
title: input?.title,
permission: input?.permission,
})
},
)
@@ -174,7 +178,13 @@ export namespace Session {
})
})
export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
export async function createNext(input: {
id?: string
title?: string
parentID?: string
directory: string
permission?: PermissionNext.Ruleset
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
version: Installation.VERSION,
@@ -182,6 +192,7 @@ export namespace Session {
directory: input.directory,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
time: {
created: Date.now(),
updated: Date.now(),

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

@@ -3,7 +3,6 @@ import { Log } from "@/util/log"
import { Identifier } from "@/id/id"
import { Session } from "."
import { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "./summary"
import { Bus } from "@/bus"
@@ -14,6 +13,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -152,32 +152,18 @@ export namespace SessionProcessor {
JSON.stringify(p.state.input) === JSON.stringify(value.input),
)
) {
const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission)
if (permission.doom_loop === "ask") {
await Permission.ask({
type: "doom_loop",
pattern: value.toolName,
sessionID: input.assistantMessage.sessionID,
messageID: input.assistantMessage.id,
callID: value.toolCallId,
title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
metadata: {
tool: value.toolName,
input: value.input,
},
})
} else if (permission.doom_loop === "deny") {
throw new Permission.RejectedError(
input.assistantMessage.sessionID,
"doom_loop",
value.toolCallId,
{
tool: value.toolName,
input: value.input,
},
`You seem to be stuck in a doom loop, please stop repeating the same action`,
)
}
const agent = await Agent.get(input.assistantMessage.agent)
await PermissionNext.ask({
permission: "doom_loop",
patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID,
metadata: {
tool: value.toolName,
input: value.input,
},
always: [value.toolName],
ruleset: agent.permission,
})
}
}
break
@@ -215,7 +201,6 @@ export namespace SessionProcessor {
status: "error",
input: value.input,
error: (value.error as any).toString(),
metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
time: {
start: match.state.time.start,
end: Date.now(),
@@ -223,7 +208,7 @@ export namespace SessionProcessor {
},
})
if (value.error instanceof Permission.RejectedError) {
if (value.error instanceof PermissionNext.RejectedError) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]

View File

@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
import { Session } from "."
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import { type Tool as AITool, tool, jsonSchema } from "ai"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
@@ -20,9 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { defer } from "../util/defer"
import { clone, mergeDeep, pipe } from "remeda"
import { clone } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { Wildcard } from "../util/wildcard"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
@@ -39,6 +38,8 @@ import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission/next"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
@@ -88,7 +89,12 @@ export namespace SessionPrompt {
.optional(),
agent: z.string().optional(),
noReply: z.boolean().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
tools: z
.record(z.string(), z.boolean())
.optional()
.describe(
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
),
system: z.string().optional(),
variant: z.string().optional(),
parts: z.array(
@@ -145,6 +151,23 @@ export namespace SessionPrompt {
const message = await createUserMessage(input)
await Session.touch(input.sessionID)
// this is backwards compatibility for allowing `tools` to be specified when
// prompting
const permissions: PermissionNext.Ruleset = []
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({
permission: tool,
action: enabled ? "allow" : "deny",
pattern: "*",
})
}
if (permissions.length > 0) {
session.permission = permissions
await Session.update(session.id, (draft) => {
draft.permission = permissions
})
}
if (input.noReply === true) {
return message
}
@@ -240,6 +263,7 @@ export namespace SessionPrompt {
using _ = defer(() => cancel(sessionID))
let step = 0
const session = await Session.get(sessionID)
while (true) {
SessionStatus.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID })
@@ -276,7 +300,7 @@ export namespace SessionPrompt {
step++
if (step === 1)
ensureTitle({
session: await Session.get(sessionID),
session,
modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID,
message: msgs.find((m) => m.info.role === "user")!,
@@ -350,28 +374,35 @@ export namespace SessionPrompt {
{ args: taskArgs },
)
let executionError: Error | undefined
const result = await taskTool
.execute(taskArgs, {
agent: task.agent,
messageID: assistantMessage.id,
sessionID: sessionID,
abort,
async metadata(input) {
await Session.updatePart({
...part,
type: "tool",
state: {
...part.state,
...input,
},
} satisfies MessageV2.ToolPart)
},
})
.catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
const taskAgent = await Agent.get(task.agent)
const taskCtx: Tool.Context = {
agent: task.agent,
messageID: assistantMessage.id,
sessionID: sessionID,
abort,
async metadata(input) {
await Session.updatePart({
...part,
type: "tool",
state: {
...part.state,
...input,
},
} satisfies MessageV2.ToolPart)
},
async ask(req) {
await PermissionNext.ask({
...req,
sessionID: sessionID,
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
})
},
}
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
await Plugin.trigger(
"tool.execute.after",
{
@@ -473,7 +504,7 @@ export namespace SessionPrompt {
// normal processing
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.maxSteps ?? Infinity
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = insertReminders({
messages: msgs,
@@ -511,7 +542,7 @@ export namespace SessionPrompt {
})
const tools = await resolveTools({
agent,
sessionID,
session,
model,
tools: lastUser.tools,
processor,
@@ -581,67 +612,73 @@ export namespace SessionPrompt {
async function resolveTools(input: {
agent: Agent.Info
model: Provider.Model
sessionID: string
session: Session.Info
tools?: Record<string, boolean>
processor: SessionProcessor.Info
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const enabledTools = pipe(
input.agent.tools,
mergeDeep(await ToolRegistry.enabled(input.agent)),
mergeDeep(input.tools ?? {}),
)
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
if (Wildcard.all(item.id, enabledTools) === false) continue
const context = (args: any, options: ToolCallOptions): Tool.Context => ({
sessionID: input.session.id,
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model },
agent: input.agent.name,
metadata: async (val: { title?: string; metadata?: any }) => {
const match = input.processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: {
start: Date.now(),
},
},
})
}
},
async ask(req) {
await PermissionNext.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
})
},
})
for (const item of await ToolRegistry.tools(input.model.providerID)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
description: item.description,
inputSchema: jsonSchema(schema as any),
async execute(args, options) {
const ctx = context(args, options)
await Plugin.trigger(
"tool.execute.before",
{
tool: item.id,
sessionID: input.sessionID,
callID: options.toolCallId,
sessionID: ctx.sessionID,
callID: ctx.callID,
},
{
args,
},
)
const result = await item.execute(args, {
sessionID: input.sessionID,
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model },
agent: input.agent.name,
metadata: async (val) => {
const match = input.processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: {
start: Date.now(),
},
},
})
}
},
})
const result = await item.execute(args, ctx)
await Plugin.trigger(
"tool.execute.after",
{
tool: item.id,
sessionID: input.sessionID,
callID: options.toolCallId,
sessionID: ctx.sessionID,
callID: ctx.callID,
},
result,
)
@@ -655,31 +692,41 @@ export namespace SessionPrompt {
},
})
}
for (const [key, item] of Object.entries(await MCP.tools())) {
if (Wildcard.all(key, enabledTools) === false) continue
const execute = item.execute
if (!execute) continue
// Wrap execute to add plugin hooks and format output
item.execute = async (args, opts) => {
const ctx = context(args, opts)
await Plugin.trigger(
"tool.execute.before",
{
tool: key,
sessionID: input.sessionID,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
},
{
args,
},
)
await ctx.ask({
permission: key,
metadata: {},
patterns: ["*"],
always: ["*"],
})
const result = await execute(args, opts)
await Plugin.trigger(
"tool.execute.after",
{
tool: key,
sessionID: input.sessionID,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
},
result,
@@ -694,7 +741,7 @@ export namespace SessionPrompt {
} else if (contentItem.type === "image") {
attachments.push({
id: Identifier.ascending("part"),
sessionID: input.sessionID,
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file",
mime: contentItem.mimeType,
@@ -834,14 +881,16 @@ export namespace SessionPrompt {
await ReadTool.init()
.then(async (t) => {
const model = await Provider.getModel(info.model.providerID, info.model.modelID)
const result = await t.execute(args, {
const readCtx: Tool.Context = {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, model },
metadata: async () => {},
})
ask: async () => {},
}
const result = await t.execute(args, readCtx)
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
@@ -893,16 +942,16 @@ export namespace SessionPrompt {
if (part.mime === "application/x-directory") {
const args = { path: filepath }
const result = await ListTool.init().then((t) =>
t.execute(args, {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true },
metadata: async () => {},
}),
)
const listCtx: Tool.Context = {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true },
metadata: async () => {},
ask: async () => {},
}
const result = await ListTool.init().then((t) => t.execute(args, listCtx))
return [
{
id: Identifier.ascending("part"),

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

@@ -6,16 +6,15 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { Agent } from "@/agent/agent"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { Wildcard } from "@/util/wildcard"
import { Permission } from "@/permission"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import path from "path"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -81,41 +80,11 @@ export const BashTool = Tool.define("bash", async () => {
if (!tree) {
throw new Error("Failed to parse command")
}
const agent = await Agent.get(ctx.agent)
const directories = new Set<string>()
if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
const checkExternalDirectory = async (dir: string) => {
if (Filesystem.contains(Instance.directory, dir)) return
const title = `This command references paths outside of ${Instance.directory}`
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [dir, path.join(dir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title,
metadata: {
command: params.command,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
command: params.command,
},
`${title} so this command is not allowed to be executed.`,
)
}
}
await checkExternalDirectory(cwd)
const permissions = agent.permission.bash
const askPatterns = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
const command = []
@@ -150,48 +119,33 @@ export const BashTool = Tool.define("bash", async () => {
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: resolved
await checkExternalDirectory(normalized)
directories.add(normalized)
}
}
}
// always allow cd if it passes above check
if (command[0] !== "cd") {
const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions)
if (action === "deny") {
throw new Error(
`The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`,
)
}
if (action === "ask") {
const pattern = (() => {
if (command.length === 0) return
const head = command[0]
// Find first non-flag argument as subcommand
const sub = command.slice(1).find((arg) => !arg.startsWith("-"))
return sub ? `${head} ${sub} *` : `${head} *`
})()
if (pattern) {
askPatterns.add(pattern)
}
}
// cd covered by above check
if (command.length && command[0] !== "cd") {
patterns.add(command.join(" "))
always.add(BashArity.prefix(command).join(" ") + "*")
}
}
if (askPatterns.size > 0) {
const patterns = Array.from(askPatterns)
await Permission.ask({
type: "bash",
pattern: patterns,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: params.command,
metadata: {
command: params.command,
patterns,
},
if (directories.size > 0) {
await ctx.ask({
permission: "external_directory",
patterns: Array.from(directories),
always: Array.from(directories).map((x) => x + "*"),
metadata: {},
})
}
if (patterns.size > 0) {
await ctx.ask({
permission: "bash",
patterns: Array.from(patterns),
always: Array.from(always),
metadata: {},
})
}

View File

@@ -1,8 +1,6 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./codesearch.txt"
import { Config } from "../config/config"
import { Permission } from "../permission"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -52,19 +50,15 @@ export const CodeSearchTool = Tool.define("codesearch", {
),
}),
async execute(params, ctx) {
const cfg = await Config.get()
if (cfg.permission?.webfetch === "ask")
await Permission.ask({
type: "codesearch",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Search code for: " + params.query,
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
await ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const codeRequest: McpCodeRequest = {
jsonrpc: "2.0",

View File

@@ -8,14 +8,12 @@ import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Snapshot } from "@/snapshot"
const MAX_DIAGNOSTICS_PER_FILE = 20
@@ -41,36 +39,18 @@ export const EditTool = Tool.define("edit", {
throw new Error("oldString and newString must be different")
}
const agent = await Agent.get(ctx.agent)
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Edit file outside working directory: ${filePath}`,
metadata: {
filepath: filePath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
}
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
}
let diff = ""
@@ -80,19 +60,15 @@ export const EditTool = Tool.define("edit", {
if (params.oldString === "") {
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
if (agent.permission.edit === "ask") {
await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Edit this file: " + filePath,
metadata: {
filePath,
diff,
},
})
}
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Bun.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
@@ -112,19 +88,15 @@ export const EditTool = Tool.define("edit", {
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
if (agent.permission.edit === "ask") {
await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Edit this file: " + filePath,
metadata: {
filePath,
diff,
},
})
}
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
@@ -137,6 +109,26 @@ export const EditTool = Tool.define("edit", {
FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = ""
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
@@ -150,18 +142,6 @@ export const EditTool = Tool.define("edit", {
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
}
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
return {
metadata: {
diagnostics,

View File

@@ -16,7 +16,17 @@ export const GlobTool = Tool.define("glob", {
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
async execute(params) {
async execute(params, ctx) {
await ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)

View File

@@ -14,11 +14,22 @@ export const GrepTool = Tool.define("grep", {
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params) {
async execute(params, ctx) {
if (!params.pattern) {
throw new Error("pattern is required")
}
await ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
})
const searchPath = params.path || Instance.directory
const rgPath = await Ripgrep.filepath()

View File

@@ -40,9 +40,18 @@ export const ListTool = Tool.define("list", {
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params) {
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) {

View File

@@ -26,7 +26,14 @@ export const LspTool = Tool.define("lsp", {
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args) => {
execute: async (args, ctx) => {
await ctx.ask({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
const uri = pathToFileURL(file).href
const position = {

View File

@@ -3,11 +3,9 @@ import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { Permission } from "../permission"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Patch } from "../patch"
import { Filesystem } from "../util/filesystem"
import { createTwoFilesPatch } from "diff"
@@ -39,7 +37,6 @@ export const PatchTool = Tool.define("patch", {
}
// Validate file paths and check permissions
const agent = await Agent.get(ctx.agent)
const fileChanges: Array<{
filePath: string
oldContent: string
@@ -55,31 +52,15 @@ export const PatchTool = Tool.define("patch", {
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Patch file outside working directory: ${filePath}`,
metadata: {
filepath: filePath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
}
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
}
switch (hunk.type) {
@@ -152,18 +133,14 @@ export const PatchTool = Tool.define("patch", {
}
// Check permissions if needed
if (agent.permission.edit === "ask") {
await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Apply patch to ${fileChanges.length} files`,
metadata: {
diff: totalDiff,
},
})
}
await ctx.ask({
permission: "edit",
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
always: ["*"],
metadata: {
diff: totalDiff,
},
})
// Apply the changes
const changedFiles: string[] = []

View File

@@ -8,8 +8,6 @@ import DESCRIPTION from "./read.txt"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { Permission } from "../permission"
import { Agent } from "@/agent/agent"
import { iife } from "@/util/iife"
const DEFAULT_READ_LIMIT = 2000
@@ -28,37 +26,27 @@ export const ReadTool = Tool.define("read", {
filepath = path.join(process.cwd(), filepath)
}
const title = path.relative(Instance.worktree, filepath)
const agent = await Agent.get(ctx.agent)
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Access file outside working directory: ${filepath}`,
metadata: {
filepath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
}
await ctx.ask({
permission: "external_directory",
patterns: [parentDir],
always: [parentDir + "/*"],
metadata: {
filepath,
parentDir,
},
})
}
await ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})
const block = iife(() => {
const basename = path.basename(filepath)
const whitelist = [".env.sample", ".env.example", ".example", ".env.template"]

View File

@@ -2,7 +2,6 @@ import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { ListTool } from "./ls"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
@@ -135,27 +134,4 @@ export namespace ToolRegistry {
)
return result
}
export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {}
if (agent.permission.edit === "deny") {
result["edit"] = false
result["write"] = false
}
if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
result["bash"] = false
}
if (agent.permission.webfetch === "deny") {
result["webfetch"] = false
result["codesearch"] = false
result["websearch"] = false
}
// Disable skill tool if all skills are denied
if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
result["skill"] = false
}
return result
}
}

View File

@@ -2,21 +2,13 @@ import path from "path"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { Agent } from "../agent/agent"
import { Permission } from "../permission"
import { Wildcard } from "../util/wildcard"
import { ConfigMarkdown } from "../config/markdown"
const parameters = z.object({
name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
})
export const SkillTool = Tool.define("skill", async () => {
const skills = await Skill.all()
export const SkillTool: Tool.Info<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 +17,61 @@ export const SkillTool: Tool.Info<typeof parameters> = {
return action !== "deny"
})
}
*/
const description =
accessibleSkills.length === 0
? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"<available_skills>",
...accessibleSkills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` </skill>`,
]),
"</available_skills>",
].join(" ")
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 skill = await Skill.get(params.name)
const skill = await Skill.get(params.name)
if (!skill) {
const available = Skill.all().then((x) => Object.keys(x).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
if (!skill) {
const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
await ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
// Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location)
const dir = path.dirname(skill.location)
// Check permission using Wildcard.all on the skill name
const permissions = agent.permission.skill
const action = Wildcard.all(params.name, permissions)
// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
if (action === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"skill",
ctx.callID,
{ skill: params.name },
`Access to skill "${params.name}" is denied for agent "${agent.name}".`,
)
}
if (action === "ask") {
await Permission.ask({
type: "skill",
pattern: params.name,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Load skill: ${skill.name}`,
metadata: { name: skill.name, description: skill.description },
})
}
// Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location)
const dir = path.dirname(skill.location)
// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join(
"\n",
)
return {
title: `Loaded skill: ${skill.name}`,
output,
metadata: {
name: skill.name,
dir,
},
}
},
}
},
}
return {
title: `Loaded skill: ${skill.name}`,
output,
metadata: {
name: skill.name,
dir,
},
}
},
}
})

View File

@@ -29,6 +29,17 @@ export const TaskTool = Tool.define("task", async () => {
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get()
await ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const session = await iife(async () => {
@@ -40,6 +51,28 @@ export const TaskTool = Tool.define("task", async () => {
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "todoread",
pattern: "*",
action: "deny",
},
{
permission: "task",
pattern: "*",
action: "deny",
},
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
permission: t,
})) ?? []),
],
})
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
@@ -88,7 +121,6 @@ export const TaskTool = Tool.define("task", async () => {
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
const config = await Config.get()
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
@@ -102,7 +134,6 @@ export const TaskTool = Tool.define("task", async () => {
todoread: false,
task: false,
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
...agent.tools,
},
parts: promptParts,
})

View File

@@ -8,9 +8,16 @@ export const TodoWriteTool = Tool.define("todowrite", {
parameters: z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
}),
async execute(params, opts) {
async execute(params, ctx) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await Todo.update({
sessionID: opts.sessionID,
sessionID: ctx.sessionID,
todos: params.todos,
})
return {
@@ -26,8 +33,15 @@ export const TodoWriteTool = Tool.define("todowrite", {
export const TodoReadTool = Tool.define("todoread", {
description: "Use this tool to read your todo list",
parameters: z.object({}),
async execute(_params, opts) {
const todos = await Todo.get(opts.sessionID)
async execute(_params, ctx) {
await ctx.ask({
permission: "todoread",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const todos = await Todo.get(ctx.sessionID)
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {

View File

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

View File

@@ -2,8 +2,6 @@ import z from "zod"
import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { Config } from "../config/config"
import { Permission } from "../permission"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -25,20 +23,16 @@ export const WebFetchTool = Tool.define("webfetch", {
throw new Error("URL must start with http:// or https://")
}
const cfg = await Config.get()
if (cfg.permission?.webfetch === "ask")
await Permission.ask({
type: "webfetch",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Fetch content from: " + params.url,
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
await ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)

View File

@@ -1,8 +1,6 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./websearch.txt"
import { Config } from "../config/config"
import { Permission } from "../permission"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -59,22 +57,18 @@ export const WebSearchTool = Tool.define("websearch", {
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
async execute(params, ctx) {
const cfg = await Config.get()
if (cfg.permission?.webfetch === "ask")
await Permission.ask({
type: "websearch",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Search web for: " + params.query,
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
await ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
const searchRequest: McpSearchRequest = {
jsonrpc: "2.0",

View File

@@ -2,14 +2,14 @@ import z from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { trimDiff } from "./edit"
const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -21,55 +21,29 @@ export const WriteTool = Tool.define("write", {
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
const agent = await Agent.get(ctx.agent)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
/* TODO
if (!Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Write file outside working directory: ${filepath}`,
metadata: {
filepath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
}
...
}
*/
const file = Bun.file(filepath)
const exists = await file.exists()
const contentOld = exists ? await file.text() : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)
if (agent.permission.edit === "ask")
await Permission.ask({
type: "write",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
metadata: {
filePath: filepath,
content: params.content,
exists,
},
})
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
})
await Bun.write(filepath, params.content)
await Bus.publish(File.Event.Edited, {

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",
}),
)
},
})
})
@@ -472,7 +478,7 @@ Helper subagent prompt`,
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["helper"]).toEqual({
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
mode: "subagent",
@@ -534,36 +540,22 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
})
})
test("compaction config defaults to true when not specified", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// When not specified, compaction should be undefined (defaults handled in usage)
expect(config.compaction).toBeUndefined()
},
})
})
// Legacy tools migration tests
test("compaction config can disable auto compaction", async () => {
test("migrates legacy tools config to permissions - allow", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
compaction: {
auto: false,
agent: {
test: {
tools: {
bash: true,
read: true,
},
},
},
}),
)
@@ -573,21 +565,28 @@ test("compaction config can disable auto compaction", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.compaction?.auto).toBe(false)
expect(config.compaction?.prune).toBeUndefined()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
read: "allow",
})
},
})
})
test("compaction config can disable prune", async () => {
test("migrates legacy tools config to permissions - deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
compaction: {
prune: false,
agent: {
test: {
tools: {
bash: false,
webfetch: false,
},
},
},
}),
)
@@ -597,22 +596,27 @@ test("compaction config can disable prune", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.compaction?.prune).toBe(false)
expect(config.compaction?.auto).toBeUndefined()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
webfetch: "deny",
})
},
})
})
test("compaction config can disable both auto and prune", async () => {
test("migrates legacy write tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
compaction: {
auto: false,
prune: false,
agent: {
test: {
tools: {
write: true,
},
},
},
}),
)
@@ -622,8 +626,164 @@ test("compaction config can disable both auto and prune", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.compaction?.auto).toBe(false)
expect(config.compaction?.prune).toBe(false)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
},
})
})
test("migrates legacy edit tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
edit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates legacy patch tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
patch: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
},
})
})
test("migrates legacy multiedit tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
multiedit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates mixed legacy tools config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
bash: true,
write: true,
read: false,
webfetch: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
edit: "allow",
read: "deny",
webfetch: "allow",
})
},
})
})
test("merges legacy tools with existing permission config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
permission: {
glob: "allow",
},
tools: {
bash: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
})
},
})
})

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,652 @@
import { test, expect } from "bun:test"
import { PermissionNext } from "../../src/permission/next"
import { Instance } from "../../src/project/instance"
import { Storage } from "../../src/storage/storage"
import { tmpdir } from "../fixture/fixture"
// fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => {
const result = PermissionNext.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("fromConfig - object value converts to rules array", () => {
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
])
})
test("fromConfig - mixed string and object values", () => {
const result = PermissionNext.fromConfig({
bash: { "*": "allow", rm: "deny" },
edit: "allow",
webfetch: "ask",
})
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "edit", pattern: "*", action: "allow" },
{ permission: "webfetch", pattern: "*", action: "ask" },
])
})
test("fromConfig - empty object", () => {
const result = PermissionNext.fromConfig({})
expect(result).toEqual([])
})
// merge tests
test("merge - simple concatenation", () => {
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "deny" },
])
})
test("merge - adds new permission", () => {
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "edit", pattern: "*", action: "deny" }],
)
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
])
})
test("merge - concatenates rules for same permission", () => {
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "foo", action: "ask" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
expect(result).toEqual([
{ permission: "bash", pattern: "foo", action: "ask" },
{ permission: "bash", pattern: "*", action: "deny" },
])
})
test("merge - multiple rulesets", () => {
const result = PermissionNext.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "rm", action: "ask" }],
[{ permission: "edit", pattern: "*", action: "allow" }],
)
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "ask" },
{ permission: "edit", pattern: "*", action: "allow" },
])
})
test("merge - empty ruleset does nothing", () => {
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("merge - preserves rule order", () => {
const result = PermissionNext.merge(
[
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
],
[{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
)
expect(result).toEqual([
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
])
})
test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow"
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = PermissionNext.merge(defaults, config)
// Config's bash allow should override default ask
expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow")
// Other permissions should still be ask (from defaults)
expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask")
})
test("merge - config ask overrides default allow", () => {
// Simulates: defaults have bash: "allow", config sets bash: "ask"
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = PermissionNext.merge(defaults, config)
// Config's ask should override default allow
expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask")
})
// evaluate tests
test("evaluate - exact pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result).toBe("allow")
})
test("evaluate - last matching rule wins", () => {
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
])
expect(result).toBe("deny")
})
test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - glob pattern match", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - last matching glob wins", () => {
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" },
])
expect(result).toBe("deny")
})
test("evaluate - unknown permission returns ask", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result).toBe("ask")
})
test("evaluate - empty ruleset returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", [])
expect(result).toBe("ask")
})
test("evaluate - no matching pattern returns ask", () => {
const result = PermissionNext.evaluate("edit", "etc/passwd", [
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result).toBe("ask")
})
test("evaluate - empty rules array returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", [])
expect(result).toBe("ask")
})
test("evaluate - multiple matching patterns, last wins", () => {
const result = PermissionNext.evaluate("edit", "src/secret.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
])
expect(result).toBe("deny")
})
test("evaluate - non-matching patterns are skipped", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
])
expect(result).toBe("deny")
})
test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result).toBe("allow")
})
// wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result).toBe("deny")
})
test("evaluate - wildcard permission with specific pattern", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result).toBe("deny")
})
test("evaluate - glob permission pattern", () => {
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
{ permission: "mcp_*", pattern: "*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - specific permission and wildcard permission combined", () => {
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - wildcard permission does not match when specific exists", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result).toBe("allow")
})
test("evaluate - multiple matching permission patterns combine rules", () => {
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
])
expect(result).toBe("deny")
})
test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result).toBe("ask")
})
test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win
const result = PermissionNext.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
])
// With flat list, last matching rule wins - so "*" matches bash and wins
expect(result).toBe("deny")
})
test("evaluate - merges multiple rulesets", () => {
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
// approved comes after config, so rm should be denied
const result = PermissionNext.evaluate("bash", "rm", config, approved)
expect(result).toBe("deny")
})
// disabled tests
test("disabled - returns empty set when all tools allowed", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0)
})
test("disabled - disables tool when denied", () => {
const result = PermissionNext.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "deny" },
],
)
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(false)
expect(result.has("read")).toBe(false)
})
test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
const result = PermissionNext.disabled(
["edit", "write", "patch", "multiedit", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
],
)
expect(result.has("edit")).toBe(true)
expect(result.has("write")).toBe(true)
expect(result.has("patch")).toBe(true)
expect(result.has("multiedit")).toBe(true)
expect(result.has("bash")).toBe(false)
})
test("disabled - does not disable when partially denied", () => {
const result = PermissionNext.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
)
expect(result.has("bash")).toBe(false)
})
test("disabled - does not disable when action is ask", () => {
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0)
})
test("disabled - disables when wildcard deny even with specific allow", () => {
// Tool is disabled because evaluate("bash", "*", ...) returns "deny"
// The "echo *" allow rule doesn't match the "*" pattern we're checking
const result = PermissionNext.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "echo *", action: "allow" },
],
)
expect(result.has("bash")).toBe(true)
})
test("disabled - does not disable when wildcard allow after deny", () => {
const result = PermissionNext.disabled(
["bash"],
[
{ permission: "bash", pattern: "rm *", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
],
)
expect(result.has("bash")).toBe(false)
})
test("disabled - disables multiple tools", () => {
const result = PermissionNext.disabled(
["bash", "edit", "webfetch"],
[
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "deny" },
{ permission: "webfetch", pattern: "*", action: "deny" },
],
)
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("webfetch")).toBe(true)
})
test("disabled - wildcard permission denies all tools", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})
test("disabled - specific allow overrides wildcard deny", () => {
const result = PermissionNext.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
],
)
expect(result.has("bash")).toBe(false)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})
// ask tests
test("ask - resolves immediately when action is allow", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
},
})
})
test("ask - throws RejectedError when action is deny", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
).rejects.toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
test("ask - returns pending promise when action is ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
})
// Promise should be pending, not resolved
expect(promise).toBeInstanceOf(Promise)
// Don't await - just verify it returns a promise
},
})
})
// reply tests
test("reply - once resolves the pending ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test1",
sessionID: "session_test",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await PermissionNext.reply({
requestID: "permission_test1",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
},
})
})
test("reply - reject throws RejectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test2",
sessionID: "session_test",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await PermissionNext.reply({
requestID: "permission_test2",
reply: "reject",
})
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
test("reply - always persists approval and resolves", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test3",
sessionID: "session_test",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
})
await PermissionNext.reply({
requestID: "permission_test3",
reply: "always",
})
await expect(askPromise).resolves.toBeUndefined()
},
})
// Re-provide to reload state with stored permissions
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Stored approval should allow without asking
const result = await PermissionNext.ask({
sessionID: "session_test2",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
test("reply - reject cancels all pending for same session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise1 = PermissionNext.ask({
id: "permission_test4a",
sessionID: "session_same",
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
const askPromise2 = PermissionNext.ask({
id: "permission_test4b",
sessionID: "session_same",
permission: "edit",
patterns: ["foo.ts"],
metadata: {},
always: [],
ruleset: [],
})
// Catch rejections before they become unhandled
const result1 = askPromise1.catch((e) => e)
const result2 = askPromise2.catch((e) => e)
// Reject the first one
await PermissionNext.reply({
requestID: "permission_test4a",
reply: "reject",
})
// Both should be rejected
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
test("ask - checks all patterns and stops on first deny", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
).rejects.toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
test("ask - allows all patterns when all match allow rules", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
},
})
})

View File

@@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { Permission } from "../../src/permission"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
const ctx = {
sessionID: "test",
@@ -12,6 +12,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
const projectRoot = path.join(__dirname, "../..")
@@ -37,397 +38,164 @@ describe("tool.bash", () => {
})
describe("tool.bash permissions", () => {
test("allows command matching allow pattern", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"echo *": "allow",
"*": "deny",
},
},
}),
)
},
})
test("asks for bash permission with correct pattern", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const result = await bash.execute(
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "echo hello",
description: "Echo hello",
},
ctx,
testCtx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("hello")
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("bash")
expect(requests[0].patterns).toContain("echo hello")
},
})
})
test("denies command matching deny pattern", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"curl *": "deny",
"*": "allow",
},
},
}),
)
},
})
test("asks for bash permission with multiple commands", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
await expect(
bash.execute(
{
command: "curl https://example.com",
description: "Fetch URL",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("denies all commands with wildcard deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
await expect(
bash.execute(
{
command: "ls",
description: "List files",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("more specific pattern overrides general pattern", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"*": "deny",
"ls *": "allow",
"pwd*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// ls should be allowed
const result = await bash.execute(
{
command: "ls -la",
description: "List files",
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
ctx,
)
expect(result.metadata.exit).toBe(0)
// pwd should be allowed
const pwd = await bash.execute(
{
command: "pwd",
description: "Print working directory",
},
ctx,
)
expect(pwd.metadata.exit).toBe(0)
// cat should be denied
await expect(
bash.execute(
{
command: "cat /etc/passwd",
description: "Read file",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("denies dangerous subcommands while allowing safe ones", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"find *": "allow",
"find * -delete*": "deny",
"find * -exec*": "deny",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Basic find should work
const result = await bash.execute(
{
command: "find . -name '*.ts'",
description: "Find typescript files",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
// find -delete should be denied
await expect(
bash.execute(
{
command: "find . -name '*.tmp' -delete",
description: "Delete temp files",
},
ctx,
),
).rejects.toThrow("restricted")
// find -exec should be denied
await expect(
bash.execute(
{
command: "find . -name '*.ts' -exec cat {} \\;",
description: "Find and cat files",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("allows git read commands while denying writes", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"git status*": "allow",
"git log*": "allow",
"git diff*": "allow",
"git branch": "allow",
"git commit *": "deny",
"git push *": "deny",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// git status should work
const status = await bash.execute(
{
command: "git status",
description: "Git status",
},
ctx,
)
expect(status.metadata.exit).toBe(0)
// git log should work
const log = await bash.execute(
{
command: "git log --oneline -5",
description: "Git log",
},
ctx,
)
expect(log.metadata.exit).toBe(0)
// git commit should be denied
await expect(
bash.execute(
{
command: "git commit -m 'test'",
description: "Git commit",
},
ctx,
),
).rejects.toThrow("restricted")
// git push should be denied
await expect(
bash.execute(
{
command: "git push origin main",
description: "Git push",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("denies external directory access when permission is deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
bash: {
"*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Should deny cd to parent directory (cd is checked for external paths)
await expect(
bash.execute(
{
command: "cd ../",
description: "Change to parent directory",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("denies workdir outside project when external_directory is deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
bash: {
"*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
await expect(
bash.execute(
{
command: "ls",
workdir: "/tmp",
description: "List /tmp",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("handles multiple commands in sequence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"echo *": "allow",
"curl *": "deny",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// echo && echo should work
const result = await bash.execute(
}
await bash.execute(
{
command: "echo foo && echo bar",
description: "Echo twice",
},
ctx,
testCtx,
)
expect(result.metadata.output).toContain("foo")
expect(result.metadata.output).toContain("bar")
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("bash")
expect(requests[0].patterns).toContain("echo foo")
expect(requests[0].patterns).toContain("echo bar")
},
})
})
// echo && curl should fail (curl is denied)
await expect(
bash.execute(
{
command: "echo hi && curl https://example.com",
description: "Echo then curl",
},
ctx,
),
).rejects.toThrow("restricted")
test("asks for external_directory permission when cd to parent", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "cd ../",
description: "Change to parent directory",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
},
})
})
test("asks for external_directory permission when workdir is outside project", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "ls",
workdir: "/tmp",
description: "List /tmp",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain("/tmp")
},
})
})
test("includes always patterns for auto-approval", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "git log --oneline -5",
description: "Git log",
},
testCtx,
)
expect(requests.length).toBe(1)
expect(requests[0].always.length).toBeGreaterThan(0)
expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true)
},
})
})
test("does not ask for bash permission when command is cd only", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "cd .",
description: "Stay in current directory",
},
testCtx,
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeUndefined()
},
})
})

View File

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

View File

@@ -3,16 +3,17 @@ import path from "path"
import { PatchTool } from "../../src/tool/patch"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { Permission } from "../../src/permission"
import { PermissionNext } from "../../src/permission/next"
import * as fs from "fs/promises"
const ctx = {
sessionID: "test",
messageID: "",
toolCallID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
const patchTool = await PatchTool.init()
@@ -59,7 +60,8 @@ describe("tool.patch", () => {
patchTool.execute({ patchText: maliciousPatch }, ctx)
// TODO: this sucks
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(Permission.pending()[ctx.sessionID]).toBeDefined()
const pending = await PermissionNext.list()
expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined()
},
})
})

View File

@@ -3,6 +3,7 @@ import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
const ctx = {
sessionID: "test",
@@ -11,6 +12,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
describe("tool.read external_directory permission", () => {
@@ -18,14 +20,6 @@ describe("tool.read external_directory permission", () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
},
}),
)
},
})
await Instance.provide({
@@ -42,14 +36,6 @@ describe("tool.read external_directory permission", () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
},
}),
)
},
})
await Instance.provide({
@@ -62,83 +48,74 @@ describe("tool.read external_directory permission", () => {
})
})
test("denies reading absolute path outside project directory", async () => {
test("asks for external_directory permission when reading absolute path outside project", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "secret.txt"), "secret data")
},
})
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
},
}),
)
},
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow(
"not in the current working directory",
)
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true)
},
})
})
test("denies reading relative path that traverses outside project directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
},
}),
)
},
})
test("asks for external_directory permission when reading relative path outside project", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow(
"not in the current working directory",
)
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
// This will fail because file doesn't exist, but we can check if permission was asked
await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
},
})
})
test("allows reading outside project directory when external_directory is allow", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "external.txt"), "external content")
},
})
test("does not ask for external_directory permission when reading inside project", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "allow",
},
}),
)
await Bun.write(path.join(dir, "internal.txt"), "internal content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx)
expect(result.output).toContain("external content")
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeUndefined()
},
})
})

View File

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

View File

@@ -451,67 +451,32 @@ export type EventMessagePartRemoved = {
}
}
export type Permission = {
export type PermissionRequest = {
id: string
type: string
pattern?: string | Array<string>
sessionID: string
messageID: string
callID?: string
title: string
permission: string
patterns: Array<string>
metadata: {
[key: string]: unknown
}
time: {
created: number
always: Array<string>
tool?: {
messageID: string
callID: string
}
}
export type EventPermissionUpdated = {
type: "permission.updated"
properties: Permission
export type EventPermissionAsked = {
type: "permission.asked"
properties: PermissionRequest
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
permissionID: string
response: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
requestID: string
reply: "once" | "always" | "reject"
}
}
@@ -551,6 +516,40 @@ export type EventSessionCompacted = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
/**
* Unique identifier for the todo item
*/
id: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -610,6 +609,16 @@ export type EventCommandExecuted = {
}
}
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRule = {
permission: string
pattern: string
action: PermissionAction
}
export type PermissionRuleset = Array<PermissionRule>
export type Session = {
id: string
projectID: string
@@ -632,6 +641,7 @@ export type Session = {
compacting?: number
archived?: number
}
permission?: PermissionRuleset
revert?: {
messageID: string
partID?: string
@@ -756,13 +766,13 @@ export type Event =
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventPermissionUpdated
| EventPermissionAsked
| EventPermissionReplied
| EventFileEdited
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventFileEdited
| EventTodoUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -1183,11 +1193,43 @@ export type ServerConfig = {
cors?: Array<string>
}
export type PermissionActionConfig = "ask" | "allow" | "deny"
export type PermissionObjectConfig = {
[key: string]: PermissionActionConfig
}
export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig
export type PermissionConfig =
| {
read?: PermissionRuleConfig
edit?: PermissionRuleConfig
glob?: PermissionRuleConfig
grep?: PermissionRuleConfig
list?: PermissionRuleConfig
bash?: PermissionRuleConfig
task?: PermissionRuleConfig
external_directory?: PermissionRuleConfig
todowrite?: PermissionActionConfig
todoread?: PermissionActionConfig
webfetch?: PermissionActionConfig
websearch?: PermissionActionConfig
codesearch?: PermissionActionConfig
lsp?: PermissionRuleConfig
doom_loop?: PermissionActionConfig
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
}
| PermissionActionConfig
export type AgentConfig = {
model?: string
temperature?: number
top_p?: number
prompt?: string
/**
* @deprecated Use 'permission' field instead
*/
tools?: {
[key: string]: boolean
}
@@ -1197,6 +1239,9 @@ export type AgentConfig = {
*/
description?: string
mode?: "subagent" | "primary" | "all"
options?: {
[key: string]: unknown
}
/**
* Hex color code for the agent (e.g., #FF5733)
*/
@@ -1204,27 +1249,12 @@ export type AgentConfig = {
/**
* Maximum number of agentic iterations before forcing text-only response
*/
steps?: number
/**
* @deprecated Use 'steps' field instead.
*/
maxSteps?: number
permission?: {
edit?: "ask" | "allow" | "deny"
bash?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
skill?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: "ask" | "allow" | "deny"
doom_loop?: "ask" | "allow" | "deny"
external_directory?: "ask" | "allow" | "deny"
}
permission?: PermissionConfig
[key: string]:
| unknown
| string
@@ -1236,28 +1266,12 @@ export type AgentConfig = {
| "subagent"
| "primary"
| "all"
| {
[key: string]: unknown
}
| string
| number
| {
edit?: "ask" | "allow" | "deny"
bash?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
skill?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: "ask" | "allow" | "deny"
doom_loop?: "ask" | "allow" | "deny"
external_directory?: "ask" | "allow" | "deny"
}
| PermissionConfig
| undefined
}
@@ -1578,26 +1592,7 @@ export type Config = {
*/
instructions?: Array<string>
layout?: LayoutConfig
permission?: {
edit?: "ask" | "allow" | "deny"
bash?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
skill?:
| "ask"
| "allow"
| "deny"
| {
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: "ask" | "allow" | "deny"
doom_loop?: "ask" | "allow" | "deny"
external_directory?: "ask" | "allow" | "deny"
}
permission?: PermissionConfig
tools?: {
[key: string]: boolean
}
@@ -1886,34 +1881,19 @@ export type Agent = {
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
default?: boolean
topP?: number
temperature?: number
color?: string
permission: {
edit: "ask" | "allow" | "deny"
bash: {
[key: string]: "ask" | "allow" | "deny"
}
skill: {
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: "ask" | "allow" | "deny"
doom_loop?: "ask" | "allow" | "deny"
external_directory?: "ask" | "allow" | "deny"
}
permission: PermissionRuleset
model?: {
modelID: string
providerID: string
}
prompt?: string
tools: {
[key: string]: boolean
}
options: {
[key: string]: unknown
}
maxSteps?: number
steps?: number
}
export type McpStatusConnected = {
@@ -2457,6 +2437,7 @@ export type SessionCreateData = {
body?: {
parentID?: string
title?: string
permission?: PermissionRuleset
}
path?: never
query?: {
@@ -2972,6 +2953,9 @@ export type SessionPromptData = {
}
agent?: string
noReply?: boolean
/**
* @deprecated tools and permissions have been merged, you can set permissions on the session itself now
*/
tools?: {
[key: string]: boolean
}
@@ -3156,6 +3140,9 @@ export type SessionPromptAsyncData = {
}
agent?: string
noReply?: boolean
/**
* @deprecated tools and permissions have been merged, you can set permissions on the session itself now
*/
tools?: {
[key: string]: boolean
}
@@ -3391,6 +3378,41 @@ export type PermissionRespondResponses = {
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
export type PermissionReplyData = {
body?: {
reply: "once" | "always" | "reject"
}
path: {
requestID: string
}
query?: {
directory?: string
}
url: "/permission/{requestID}/reply"
}
export type PermissionReplyErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors]
export type PermissionReplyResponses = {
/**
* Permission processed successfully
*/
200: boolean
}
export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses]
export type PermissionListData = {
body?: never
path?: never
@@ -3404,7 +3426,7 @@ export type PermissionListResponses = {
/**
* List of pending permissions
*/
200: Array<Permission>
200: Array<PermissionRequest>
}
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]

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)

View File

@@ -455,8 +455,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const permission = createMemo(() => {
const next = data.store.permission?.[props.message.sessionID]?.[0]
if (!next) return undefined
if (next.callID !== part.callID) return undefined
if (!next || !next.tool) return undefined
if (next.tool!.callID !== part.callID) return undefined
return next
})
@@ -732,19 +732,20 @@ ToolRegistry.register({
const childToolPart = createMemo(() => {
const perm = childPermission()
if (!perm) return undefined
if (!perm || !perm.tool) return undefined
const sessionId = childSessionId()
if (!sessionId) return undefined
// Find the tool part that matches the permission's callID
const messages = data.store.message[sessionId] ?? []
for (const msg of messages) {
const parts = data.store.part[msg.id] ?? []
for (const part of parts) {
if (part.type === "tool" && (part as ToolPart).callID === perm.callID) {
return { part: part as ToolPart, message: msg }
}
const message = messages.findLast((m) => m.id === perm.tool!.messageID)
if (!message) return undefined
const parts = data.store.part[message.id] ?? []
for (const part of parts) {
if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) {
return { part: part as ToolPart, message }
}
}
return undefined
})

View File

@@ -2,7 +2,7 @@ import {
AssistantMessage,
Message as MessageType,
Part as PartType,
type Permission,
type PermissionRequest,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2/client"
@@ -132,7 +132,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: Permission[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
@@ -235,16 +235,18 @@ export function SessionTurn(
if (props.stepsExpanded) return emptyPermissionParts
const next = nextPermission()
if (!next) return emptyPermissionParts
if (!next || !next.tool) return emptyPermissionParts
for (const message of assistantMessages()) {
const parts = data.store.part[message.id] ?? emptyParts
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.callID === next.callID) return [{ part: tool, message }]
}
const message = assistantMessages().findLast((m) => m.id === next.tool!.messageID)
if (!message) return emptyPermissionParts
const parts = data.store.part[message.id] ?? emptyParts
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
}
return emptyPermissionParts
})

View File

@@ -1,4 +1,4 @@
import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2"
import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -14,7 +14,7 @@ type Data = {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
permission?: {
[sessionID: string]: Permission[]
[sessionID: string]: PermissionRequest[]
}
message: {
[sessionID: string]: Message[]