mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 14:44:46 +00:00
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:
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -2,11 +2,9 @@ name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- production
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- production
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -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.
|
||||
@@ -10,7 +10,13 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"permission": "ask",
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
},
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
[install]
|
||||
exact = true
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -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": {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
313
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Normal file
313
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
163
packages/opencode/src/permission/arity.ts
Normal file
163
packages/opencode/src/permission/arity.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
253
packages/opencode/src/permission/next.ts
Normal file
253
packages/opencode/src/permission/next.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -44,7 +44,7 @@ export namespace SystemPrompt {
|
||||
`</env>`,
|
||||
`<files>`,
|
||||
` ${
|
||||
project.vcs === "git"
|
||||
project.vcs === "git" && false
|
||||
? await Ripgrep.tree({
|
||||
cwd: Instance.directory,
|
||||
limit: 200,
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
33
packages/opencode/test/permission/arity.test.ts
Normal file
33
packages/opencode/test/permission/arity.test.ts
Normal 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"])
|
||||
})
|
||||
652
packages/opencode/test/permission/next.test.ts
Normal file
652
packages/opencode/test/permission/next.test.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const ctx = {
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user