mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 19:24:22 +01:00
Add agent-level permissions with whitelist/blacklist support (#1862)
This commit is contained in:
@@ -5,6 +5,7 @@ import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { mergeDeep } from "remeda"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -14,6 +15,11 @@ export namespace Agent {
|
||||
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]),
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
permission: z.object({
|
||||
edit: Config.Permission,
|
||||
bash: z.record(z.string(), Config.Permission),
|
||||
webfetch: Config.Permission.optional(),
|
||||
}),
|
||||
model: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
@@ -31,6 +37,13 @@ export namespace Agent {
|
||||
|
||||
const state = App.state("agent", async () => {
|
||||
const cfg = await Config.get()
|
||||
const defaultPermission: Info["permission"] = {
|
||||
edit: "allow",
|
||||
bash: {
|
||||
"*": "allow",
|
||||
},
|
||||
webfetch: "allow",
|
||||
}
|
||||
const result: Record<string, Info> = {
|
||||
general: {
|
||||
name: "general",
|
||||
@@ -41,17 +54,20 @@ export namespace Agent {
|
||||
todowrite: false,
|
||||
},
|
||||
options: {},
|
||||
permission: defaultPermission,
|
||||
mode: "subagent",
|
||||
},
|
||||
build: {
|
||||
name: "build",
|
||||
tools: {},
|
||||
options: {},
|
||||
permission: defaultPermission,
|
||||
mode: "primary",
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
options: {},
|
||||
permission: defaultPermission,
|
||||
tools: {
|
||||
write: false,
|
||||
edit: false,
|
||||
@@ -70,25 +86,48 @@ export namespace Agent {
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: defaultPermission,
|
||||
options: {},
|
||||
tools: {},
|
||||
}
|
||||
const { model, prompt, tools, description, temperature, top_p, mode, ...extra } = value
|
||||
const { model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
|
||||
item.options = {
|
||||
...item.options,
|
||||
...extra,
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
if (value.prompt) item.prompt = value.prompt
|
||||
if (value.tools)
|
||||
if (model) item.model = Provider.parseModel(model)
|
||||
if (prompt) item.prompt = prompt
|
||||
if (tools)
|
||||
item.tools = {
|
||||
...item.tools,
|
||||
...value.tools,
|
||||
...tools,
|
||||
}
|
||||
if (value.description) item.description = value.description
|
||||
if (value.temperature != undefined) item.temperature = value.temperature
|
||||
if (value.top_p != undefined) item.topP = value.top_p
|
||||
if (value.mode) item.mode = value.mode
|
||||
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 (permission ?? cfg.permission) {
|
||||
const merged = mergeDeep(cfg.permission ?? {}, permission ?? {})
|
||||
if (merged.edit) item.permission.edit = merged.edit
|
||||
if (merged.webfetch) item.permission.webfetch = merged.webfetch
|
||||
if (merged.bash) {
|
||||
if (typeof merged.bash === "string") {
|
||||
item.permission.bash = {
|
||||
"*": merged.bash,
|
||||
}
|
||||
}
|
||||
// if granular permissions are provided, default to "ask"
|
||||
if (typeof merged.bash === "object") {
|
||||
item.permission.bash = mergeDeep(
|
||||
{
|
||||
"*": "ask",
|
||||
},
|
||||
merged.bash,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -164,6 +164,9 @@ export namespace Config {
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
|
||||
export type Permission = z.infer<typeof Permission>
|
||||
|
||||
export const Agent = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
@@ -174,6 +177,13 @@ export namespace Config {
|
||||
disable: z.boolean().optional(),
|
||||
description: z.string().optional().describe("Description of when to use the agent"),
|
||||
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
|
||||
permission: z
|
||||
.object({
|
||||
edit: Permission.optional(),
|
||||
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
||||
webfetch: Permission.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.openapi({
|
||||
@@ -243,9 +253,6 @@ export namespace Config {
|
||||
})
|
||||
export type Layout = z.infer<typeof Layout>
|
||||
|
||||
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
|
||||
export type Permission = z.infer<typeof Permission>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
|
||||
@@ -21,6 +21,9 @@ import { GithubCommand } from "./cli/cmd/github"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
try {
|
||||
} catch (e) {}
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
|
||||
@@ -155,7 +155,7 @@ export namespace Permission {
|
||||
public readonly permissionID: string,
|
||||
public readonly toolCallID?: string,
|
||||
) {
|
||||
super(`The user rejected permission to use this functionality`)
|
||||
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,6 +523,7 @@ export namespace Session {
|
||||
t.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: new AbortController().signal,
|
||||
agent: agent.name,
|
||||
messageID: userMsg.id,
|
||||
metadata: async () => {},
|
||||
}),
|
||||
@@ -765,7 +766,7 @@ export namespace Session {
|
||||
|
||||
const enabledTools = pipe(
|
||||
agent.tools,
|
||||
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID)),
|
||||
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)),
|
||||
mergeDeep(input.tools ?? {}),
|
||||
)
|
||||
for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
|
||||
@@ -791,6 +792,7 @@ export namespace Session {
|
||||
abort: options.abortSignal!,
|
||||
messageID: assistantMsg.id,
|
||||
callID: options.toolCallId,
|
||||
agent: agent.name,
|
||||
metadata: async (val) => {
|
||||
const match = processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
import { App } from "../app/app"
|
||||
import { Permission } from "../permission"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { $ } from "bun"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000
|
||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||
@@ -40,20 +40,8 @@ export const BashTool = Tool.define("bash", {
|
||||
async execute(params, ctx) {
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||
const app = App.info()
|
||||
const cfg = await Config.get()
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
const permissions = (() => {
|
||||
const value = cfg.permission?.bash
|
||||
if (!value)
|
||||
return {
|
||||
"*": "allow",
|
||||
}
|
||||
if (typeof value === "string")
|
||||
return {
|
||||
"*": value,
|
||||
}
|
||||
return value
|
||||
})()
|
||||
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
|
||||
|
||||
let needsAsk = false
|
||||
for (const node of tree.rootNode.descendantsOfType("command")) {
|
||||
@@ -93,17 +81,10 @@ export const BashTool = Tool.define("bash", {
|
||||
|
||||
// always allow cd if it passes above check
|
||||
if (!needsAsk && command[0] !== "cd") {
|
||||
const action = (() => {
|
||||
for (const [pattern, value] of Object.entries(permissions)) {
|
||||
const match = Wildcard.match(node.text, pattern)
|
||||
log.info("checking", { text: node.text.trim(), pattern, match })
|
||||
if (match) return value
|
||||
}
|
||||
return "ask"
|
||||
})()
|
||||
const action = Wildcard.all(node.text, permissions)
|
||||
if (action === "deny") {
|
||||
throw new Error(
|
||||
"The user has specifically restricted access to this command, you are not allowed to execute it.",
|
||||
`The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`,
|
||||
)
|
||||
}
|
||||
if (action === "ask") needsAsk = true
|
||||
|
||||
@@ -14,8 +14,8 @@ import { App } from "../app/app"
|
||||
import { File } from "../file"
|
||||
import { Bus } from "../bus"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
@@ -40,7 +40,7 @@ export const EditTool = Tool.define("edit", {
|
||||
throw new Error(`File ${filePath} is not in the current working directory`)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
@@ -48,7 +48,7 @@ export const EditTool = Tool.define("edit", {
|
||||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
if (cfg.permission?.edit === "ask") {
|
||||
if (agent.permission.edit === "ask") {
|
||||
await Permission.ask({
|
||||
type: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
@@ -77,7 +77,7 @@ export const EditTool = Tool.define("edit", {
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
if (cfg.permission?.edit === "ask") {
|
||||
if (agent.permission.edit === "ask") {
|
||||
await Permission.ask({
|
||||
type: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { Config } from "../config/config"
|
||||
import type { Agent } from "../agent/agent"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const ALL = [
|
||||
@@ -66,20 +66,23 @@ export namespace ToolRegistry {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function enabled(_providerID: string, _modelID: string): Promise<Record<string, boolean>> {
|
||||
const cfg = await Config.get()
|
||||
export async function enabled(
|
||||
_providerID: string,
|
||||
_modelID: string,
|
||||
agent: Agent.Info,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const result: Record<string, boolean> = {}
|
||||
result["patch"] = false
|
||||
|
||||
if (cfg.permission?.edit === "deny") {
|
||||
if (agent.permission.edit === "deny") {
|
||||
result["edit"] = false
|
||||
result["patch"] = false
|
||||
result["write"] = false
|
||||
}
|
||||
if (cfg?.permission?.bash === "deny") {
|
||||
if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
|
||||
result["bash"] = false
|
||||
}
|
||||
if (cfg?.permission?.webfetch === "deny") {
|
||||
if (agent.permission.webfetch === "deny") {
|
||||
result["webfetch"] = false
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export namespace Tool {
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
callID?: string
|
||||
abort: AbortSignal
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
|
||||
@@ -8,8 +8,8 @@ import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
@@ -28,8 +28,8 @@ export const WriteTool = Tool.define("write", {
|
||||
const exists = await file.exists()
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.permission?.edit === "ask")
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
if (agent.permission.edit === "ask")
|
||||
await Permission.ask({
|
||||
type: "write",
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -8,6 +8,7 @@ const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
@@ -33,7 +34,7 @@ describe("tool.bash", () => {
|
||||
|
||||
test("cd ../ should fail outside of project root", async () => {
|
||||
await App.provide({ cwd: projectRoot }, async () => {
|
||||
await expect(
|
||||
expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
|
||||
@@ -8,6 +8,7 @@ const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user