mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
sync
This commit is contained in:
parent
1898bc6574
commit
c176b46bf9
11 changed files with 736 additions and 351 deletions
|
|
@ -450,7 +450,7 @@ export namespace Config {
|
|||
permission: Permission.optional(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.transform((agent) => {
|
||||
.transform((agent, ctx) => {
|
||||
const knownKeys = new Set([
|
||||
"model",
|
||||
"prompt",
|
||||
|
|
|
|||
|
|
@ -12,12 +12,22 @@ import z from "zod"
|
|||
export namespace PermissionNext {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Rule = Config.PermissionObject.meta({
|
||||
ref: "PermissionRule",
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = z.record(z.string(), Rule).meta({
|
||||
export const Ruleset = z.record(z.string(), Rule.array()).meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
|
@ -26,12 +36,15 @@ export namespace PermissionNext {
|
|||
const ruleset: Ruleset = {}
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset[key] = {
|
||||
"*": value,
|
||||
}
|
||||
ruleset[key] = [
|
||||
{
|
||||
action: value,
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
continue
|
||||
}
|
||||
ruleset[key] = value
|
||||
ruleset[key] = Object.entries(value).map(([pattern, action]) => ({ pattern, action }))
|
||||
}
|
||||
return ruleset
|
||||
}
|
||||
|
|
@ -39,37 +52,27 @@ export namespace PermissionNext {
|
|||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
const result: Ruleset = {}
|
||||
for (const ruleset of rulesets) {
|
||||
for (const [permission, rule] of Object.entries(ruleset)) {
|
||||
for (const existingPerm of Object.keys(result)) {
|
||||
if (Wildcard.match(existingPerm, permission)) {
|
||||
for (const [pattern, action] of Object.entries(rule)) {
|
||||
for (const existingPattern of Object.keys(result[existingPerm])) {
|
||||
if (Wildcard.match(existingPattern, pattern)) {
|
||||
result[existingPerm][existingPattern] = action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result[permission] ??= {}
|
||||
for (const [pattern, action] of Object.entries(rule)) {
|
||||
result[permission][pattern] = action
|
||||
for (const [permission, rules] of Object.entries(ruleset)) {
|
||||
if (!result[permission]) {
|
||||
result[permission] = rules
|
||||
continue
|
||||
}
|
||||
result[permission] = result[permission].concat(rules)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return result as Ruleset
|
||||
}
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: Identifier.schema("permission"),
|
||||
sessionID: Identifier.schema("session"),
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
permission: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
|
|
@ -77,8 +80,8 @@ export namespace PermissionNext {
|
|||
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Response = z.enum(["once", "always", "reject"])
|
||||
export type Response = z.infer<typeof Response>
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: z.string(),
|
||||
|
|
@ -86,7 +89,15 @@ export namespace PermissionNext {
|
|||
})
|
||||
|
||||
export const Event = {
|
||||
Requested: BusEvent.define("permission.requested", Request),
|
||||
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(() => {
|
||||
|
|
@ -109,7 +120,7 @@ export namespace PermissionNext {
|
|||
}
|
||||
})
|
||||
|
||||
export const request = fn(
|
||||
export const ask = fn(
|
||||
Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
}),
|
||||
|
|
@ -132,7 +143,7 @@ export namespace PermissionNext {
|
|||
resolve,
|
||||
reject,
|
||||
}
|
||||
Bus.publish(Event.Requested, info)
|
||||
Bus.publish(Event.Asked, info)
|
||||
})
|
||||
}
|
||||
if (action === "allow") continue
|
||||
|
|
@ -142,38 +153,62 @@ export namespace PermissionNext {
|
|||
|
||||
export const respond = fn(
|
||||
z.object({
|
||||
permissionID: Identifier.schema("permission"),
|
||||
response: Response,
|
||||
requestID: Identifier.schema("permission"),
|
||||
response: Reply,
|
||||
}),
|
||||
async (input) => {
|
||||
const existing = state().pending[input.permissionID]
|
||||
const existing = state().pending[input.requestID]
|
||||
if (!existing) return
|
||||
delete state().pending[input.requestID]
|
||||
if (input.response === "reject") {
|
||||
existing.reject(new RejectedError())
|
||||
return
|
||||
}
|
||||
if (input.response === "once") {
|
||||
existing.resolve()
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.response,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.response === "always") {
|
||||
existing.resolve()
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.response,
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"])
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ruleset: Ruleset): Action {
|
||||
log.info("evaluate", { permission, pattern, ruleset })
|
||||
for (const [permissionPattern, rule] of sortBy(Object.entries(ruleset), [([k]) => k.length, "desc"])) {
|
||||
if (!Wildcard.match(permission, permissionPattern)) continue
|
||||
for (const [p, action] of sortBy(Object.entries(rule), [([k]) => k.length, "desc"])) {
|
||||
if (!Wildcard.match(pattern, p)) continue
|
||||
return action
|
||||
const rules: Rule[] = []
|
||||
const entries = sortBy(Object.entries(ruleset), ([k]) => k.length)
|
||||
for (const [permPattern, permRules] of entries) {
|
||||
if (Wildcard.match(permission, permPattern)) {
|
||||
rules.push(...permRules)
|
||||
}
|
||||
}
|
||||
return "ask"
|
||||
const match = rules.findLast((rule) => Wildcard.match(pattern, rule.pattern))
|
||||
return match?.action ?? "ask"
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
||||
|
||||
export function disabledTools(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const disabled = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
if (evaluate(permission, "*", ruleset) === "deny") {
|
||||
disabled.add(tool)
|
||||
}
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
export class RejectedError extends Error {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,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" })
|
||||
|
|
@ -188,9 +188,11 @@ export namespace LLM {
|
|||
}
|
||||
|
||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
|
||||
const enabled = pipe({}, 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.disabledTools(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -578,13 +578,7 @@ export namespace SessionPrompt {
|
|||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
const enabledTools = pipe(
|
||||
input.agent.tools,
|
||||
mergeDeep(await ToolRegistry.enabled(input.agent)),
|
||||
mergeDeep(input.tools ?? {}),
|
||||
)
|
||||
for (const item of await ToolRegistry.tools(input.model.providerID)) {
|
||||
if (Wildcard.all(item.id, enabledTools) === false) continue
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
|
|
@ -647,7 +641,6 @@ export namespace SessionPrompt {
|
|||
})
|
||||
}
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
if (Wildcard.all(key, enabledTools) === false) continue
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
|
|||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Tool } from "./tool"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Config } from "../config/config"
|
||||
|
|
@ -132,20 +131,4 @@ export namespace ToolRegistry {
|
|||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
|
||||
const result: Record<string, boolean> = {}
|
||||
|
||||
for (const [tool, action] of Object.entries(agent.permission)) {
|
||||
if (!Bun.deepEquals(action, { "*": "deny" })) continue
|
||||
result[tool] = false
|
||||
if (tool === "edit") {
|
||||
result["write"] = false
|
||||
result["patch"] = false
|
||||
result["multiedit"] = false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import z from "zod"
|
|||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { Permission } from "../permission"
|
||||
import DESCRIPTION from "./write.txt"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
|
|
@ -60,7 +59,7 @@ export const WriteTool = Tool.define("write", {
|
|||
const exists = await file.exists()
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
await PermissionNext.request({
|
||||
await PermissionNext.ask({
|
||||
permission: "edit",
|
||||
title: "tbd",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import { test, expect } from "bun:test"
|
|||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
|
||||
// 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()
|
||||
|
|
@ -30,8 +37,8 @@ test("build agent has correct default properties", async () => {
|
|||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(build?.permission.edit?.["*"]).toBe("allow")
|
||||
expect(build?.permission.bash?.["*"]).toBe("allow")
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -43,8 +50,10 @@ test("plan agent denies edits except .opencode/plan/*", async () => {
|
|||
fn: async () => {
|
||||
const plan = await Agent.get("plan")
|
||||
expect(plan).toBeDefined()
|
||||
expect(plan?.permission.edit?.["*"]).toBe("deny")
|
||||
expect(plan?.permission.edit?.[".opencode/plan/*"]).toBe("allow")
|
||||
// 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")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -57,10 +66,10 @@ test("explore agent denies edit and write", async () => {
|
|||
const explore = await Agent.get("explore")
|
||||
expect(explore).toBeDefined()
|
||||
expect(explore?.mode).toBe("subagent")
|
||||
expect(explore?.permission.edit?.["*"]).toBe("deny")
|
||||
expect(explore?.permission.write?.["*"]).toBe("deny")
|
||||
expect(explore?.permission.todoread?.["*"]).toBe("deny")
|
||||
expect(explore?.permission.todowrite?.["*"]).toBe("deny")
|
||||
expect(evalPerm(explore, "edit")).toBe("deny")
|
||||
expect(evalPerm(explore, "write")).toBe("deny")
|
||||
expect(evalPerm(explore, "todoread")).toBe("deny")
|
||||
expect(evalPerm(explore, "todowrite")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -74,8 +83,8 @@ test("general agent denies todo tools", async () => {
|
|||
expect(general).toBeDefined()
|
||||
expect(general?.mode).toBe("subagent")
|
||||
expect(general?.hidden).toBe(true)
|
||||
expect(general?.permission.todoread?.["*"]).toBe("deny")
|
||||
expect(general?.permission.todowrite?.["*"]).toBe("deny")
|
||||
expect(evalPerm(general, "todoread")).toBe("deny")
|
||||
expect(evalPerm(general, "todowrite")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -88,7 +97,9 @@ test("compaction agent denies all permissions", async () => {
|
|||
const compaction = await Agent.get("compaction")
|
||||
expect(compaction).toBeDefined()
|
||||
expect(compaction?.hidden).toBe(true)
|
||||
expect(compaction?.permission["*"]?.["*"]).toBe("deny")
|
||||
expect(evalPerm(compaction, "bash")).toBe("deny")
|
||||
expect(evalPerm(compaction, "edit")).toBe("deny")
|
||||
expect(evalPerm(compaction, "read")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -189,8 +200,10 @@ test("agent permission config merges with defaults", async () => {
|
|||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.permission.bash?.["rm -rf *"]).toBe("deny")
|
||||
expect(build?.permission.edit?.["*"]).toBe("allow")
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -208,7 +221,7 @@ test("global permission config applies to all agents", async () => {
|
|||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.permission.bash?.["*"]).toBe("deny")
|
||||
expect(evalPerm(build, "bash")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -373,8 +386,8 @@ test("default permission includes doom_loop and external_directory as ask", asyn
|
|||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.permission.doom_loop?.["*"]).toBe("ask")
|
||||
expect(build?.permission.external_directory?.["*"]).toBe("ask")
|
||||
expect(evalPerm(build, "doom_loop")).toBe("ask")
|
||||
expect(evalPerm(build, "external_directory")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -385,7 +398,7 @@ test("webfetch is allowed by default", async () => {
|
|||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.permission.webfetch?.["*"]).toBe("allow")
|
||||
expect(evalPerm(build, "webfetch")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -407,8 +420,8 @@ test("legacy tools config converts to permissions", async () => {
|
|||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.permission.bash?.["*"]).toBe("deny")
|
||||
expect(build?.permission.read?.["*"]).toBe("deny")
|
||||
expect(evalPerm(build, "bash")).toBe("deny")
|
||||
expect(evalPerm(build, "read")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -429,7 +442,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
|
|||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.permission.edit?.["*"]).toBe("deny")
|
||||
expect(evalPerm(build, "edit")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -507,3 +507,251 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Legacy tools migration tests
|
||||
|
||||
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",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
bash: true,
|
||||
read: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "allow",
|
||||
read: "allow",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
bash: false,
|
||||
webfetch: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "deny",
|
||||
webfetch: "deny",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
write: 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 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",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import { PermissionNext } from "../../src/permission/next"
|
|||
|
||||
// fromConfig tests
|
||||
|
||||
test("fromConfig - string value becomes wildcard", () => {
|
||||
test("fromConfig - string value becomes wildcard rule", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: "allow" })
|
||||
expect(result).toEqual({ bash: { "*": "allow" } })
|
||||
expect(result).toEqual({ bash: [{ pattern: "*", action: "allow" }] })
|
||||
})
|
||||
|
||||
test("fromConfig - object value stays as-is", () => {
|
||||
test("fromConfig - object value converts to rules array", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
expect(result).toEqual({ bash: { "*": "allow", rm: "deny" } })
|
||||
expect(result).toEqual({
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "rm", action: "deny" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("fromConfig - mixed string and object values", () => {
|
||||
|
|
@ -20,9 +25,12 @@ test("fromConfig - mixed string and object values", () => {
|
|||
webfetch: "ask",
|
||||
})
|
||||
expect(result).toEqual({
|
||||
bash: { "*": "allow", rm: "deny" },
|
||||
edit: { "*": "allow" },
|
||||
webfetch: { "*": "ask" },
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "rm", action: "deny" },
|
||||
],
|
||||
edit: [{ pattern: "*", action: "allow" }],
|
||||
webfetch: [{ pattern: "*", action: "ask" }],
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -33,127 +41,144 @@ test("fromConfig - empty object", () => {
|
|||
|
||||
// merge tests
|
||||
|
||||
test("merge - simple override", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { "*": "deny" } })
|
||||
expect(result).toEqual({ bash: { "*": "deny" } })
|
||||
})
|
||||
|
||||
test("merge - adds new permission", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { edit: { "*": "deny" } })
|
||||
test("merge - simple concatenation", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ bash: [{ pattern: "*", action: "allow" }] },
|
||||
{ bash: [{ pattern: "*", action: "deny" }] },
|
||||
)
|
||||
expect(result).toEqual({
|
||||
bash: { "*": "allow" },
|
||||
edit: { "*": "deny" },
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "*", action: "deny" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - wildcard overwrites specific patterns", () => {
|
||||
const result = PermissionNext.merge({ bash: { foo: "ask", bar: "allow" } }, { bash: { "*": "deny" } })
|
||||
expect(result).toEqual({ bash: { foo: "deny", bar: "deny", "*": "deny" } })
|
||||
})
|
||||
|
||||
test("merge - specific pattern after wildcard", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "deny" } }, { bash: { foo: "allow" } })
|
||||
expect(result).toEqual({ bash: { "*": "deny", foo: "allow" } })
|
||||
})
|
||||
|
||||
test("merge - glob pattern overwrites matching patterns", () => {
|
||||
test("merge - adds new permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ bash: { "foo/bar": "ask", "foo/baz": "allow", other: "deny" } },
|
||||
{ bash: { "foo/*": "deny" } },
|
||||
{ bash: [{ pattern: "*", action: "allow" }] },
|
||||
{ edit: [{ pattern: "*", action: "deny" }] },
|
||||
)
|
||||
expect(result).toEqual({ bash: { "foo/bar": "deny", "foo/baz": "deny", other: "deny", "foo/*": "deny" } })
|
||||
expect(result).toEqual({
|
||||
bash: [{ pattern: "*", action: "allow" }],
|
||||
edit: [{ pattern: "*", action: "deny" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - concatenates rules for same permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ bash: [{ pattern: "foo", action: "ask" }] },
|
||||
{ bash: [{ pattern: "*", action: "deny" }] },
|
||||
)
|
||||
expect(result).toEqual({
|
||||
bash: [
|
||||
{ pattern: "foo", action: "ask" },
|
||||
{ pattern: "*", action: "deny" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - multiple rulesets", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { rm: "ask" } }, { edit: { "*": "allow" } })
|
||||
const result = PermissionNext.merge(
|
||||
{ bash: [{ pattern: "*", action: "allow" }] },
|
||||
{ bash: [{ pattern: "rm", action: "ask" }] },
|
||||
{ edit: [{ pattern: "*", action: "allow" }] },
|
||||
)
|
||||
expect(result).toEqual({
|
||||
bash: { "*": "allow", rm: "ask" },
|
||||
edit: { "*": "allow" },
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "rm", action: "ask" },
|
||||
],
|
||||
edit: [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - empty ruleset does nothing", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, {})
|
||||
expect(result).toEqual({ bash: { "*": "allow" } })
|
||||
const result = PermissionNext.merge({ bash: [{ pattern: "*", action: "allow" }] }, {})
|
||||
expect(result).toEqual({ bash: [{ pattern: "*", action: "allow" }] })
|
||||
})
|
||||
|
||||
test("merge - nested glob patterns overwrites matching", () => {
|
||||
test("merge - preserves rule order", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ edit: { "src/components/Button.tsx": "allow", "src/components/Input.tsx": "allow" } },
|
||||
{ edit: { "src/components/*": "deny" } },
|
||||
{
|
||||
edit: [
|
||||
{ pattern: "src/*", action: "allow" },
|
||||
{ pattern: "src/secret/*", action: "deny" },
|
||||
],
|
||||
},
|
||||
{ edit: [{ pattern: "src/secret/ok.ts", action: "allow" }] },
|
||||
)
|
||||
expect(result).toEqual({
|
||||
edit: { "src/components/Button.tsx": "deny", "src/components/Input.tsx": "deny", "src/components/*": "deny" },
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - non-matching glob preserves existing", () => {
|
||||
const result = PermissionNext.merge({ edit: { "src/foo.ts": "allow" } }, { edit: { "test/*": "deny" } })
|
||||
expect(result).toEqual({ edit: { "src/foo.ts": "allow", "test/*": "deny" } })
|
||||
})
|
||||
|
||||
test("merge - wildcard permission overwrites all other permissions", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ bash: { "/bin/ls": "allow" }, edit: { "src/*": "allow" } },
|
||||
{ "*": { "*": "ask" } },
|
||||
)
|
||||
// The wildcard permission should overwrite existing permissions' values
|
||||
expect(result).toEqual({
|
||||
bash: { "/bin/ls": "ask" },
|
||||
edit: { "src/*": "ask" },
|
||||
"*": { "*": "ask" },
|
||||
edit: [
|
||||
{ pattern: "src/*", action: "allow" },
|
||||
{ pattern: "src/secret/*", action: "deny" },
|
||||
{ pattern: "src/secret/ok.ts", action: "allow" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
// evaluate tests
|
||||
|
||||
test("evaluate - exact permission and pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", { bash: { rm: "deny" } })
|
||||
test("evaluate - exact pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", { bash: [{ pattern: "rm", action: "deny" }] })
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", { bash: { "*": "allow" } })
|
||||
const result = PermissionNext.evaluate("bash", "rm", { bash: [{ pattern: "*", action: "allow" }] })
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - specific pattern takes precedence over wildcard", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", { bash: { "*": "allow", rm: "deny" } })
|
||||
test("evaluate - last matching rule wins", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", {
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "rm", action: "deny" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", {
|
||||
bash: [
|
||||
{ pattern: "rm", action: "deny" },
|
||||
{ pattern: "*", action: "allow" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - glob pattern match", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", { edit: { "src/*": "allow" } })
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", { edit: [{ pattern: "src/*", action: "allow" }] })
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - more specific glob takes precedence", () => {
|
||||
test("evaluate - last matching glob wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", {
|
||||
edit: { "src/*": "deny", "src/components/*": "allow" },
|
||||
edit: [
|
||||
{ pattern: "src/*", action: "deny" },
|
||||
{ pattern: "src/components/*", action: "allow" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", { "*": { "*": "deny" } })
|
||||
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", {
|
||||
edit: [
|
||||
{ pattern: "src/components/*", action: "allow" },
|
||||
{ pattern: "src/*", action: "deny" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - specific permission takes precedence over wildcard permission", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", {
|
||||
"*": { "*": "deny" },
|
||||
bash: { "*": "allow" },
|
||||
test("evaluate - unknown permission returns ask", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", {
|
||||
bash: [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - unknown permission with wildcard fallback", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", { "*": { "*": "ask" } })
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - unknown permission without wildcard returns ask", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", { bash: { "*": "allow" } })
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
|
|
@ -163,29 +188,218 @@ test("evaluate - empty ruleset returns ask", () => {
|
|||
})
|
||||
|
||||
test("evaluate - no matching pattern returns ask", () => {
|
||||
const result = PermissionNext.evaluate("edit", "etc/passwd", { edit: { "src/*": "allow" } })
|
||||
const result = PermissionNext.evaluate("edit", "etc/passwd", { edit: [{ pattern: "src/*", action: "allow" }] })
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - glob permission pattern", () => {
|
||||
const result = PermissionNext.evaluate("mcp_server_tool", "anything", {
|
||||
"mcp_*": { "*": "allow" },
|
||||
test("evaluate - empty rules array returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", { bash: [] })
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching patterns, last wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/secret.ts", {
|
||||
edit: [
|
||||
{ pattern: "*", action: "ask" },
|
||||
{ pattern: "src/*", action: "allow" },
|
||||
{ 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", {
|
||||
edit: [
|
||||
{ pattern: "*", action: "ask" },
|
||||
{ pattern: "test/*", action: "deny" },
|
||||
{ pattern: "src/*", action: "allow" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - specific permission over glob permission", () => {
|
||||
const result = PermissionNext.evaluate("mcp_dangerous", "anything", {
|
||||
"mcp_*": { "*": "allow" },
|
||||
mcp_dangerous: { "*": "deny" },
|
||||
test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", {
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "/bin/rm", action: "deny" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - combined permission and pattern specificity", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/secret.ts", {
|
||||
"*": { "*": "ask" },
|
||||
edit: { "*": "allow", "src/secret.ts": "deny" },
|
||||
test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", {
|
||||
bash: [
|
||||
{ pattern: "/bin/rm", action: "deny" },
|
||||
{ pattern: "*", action: "allow" },
|
||||
],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
// wildcard permission tests
|
||||
|
||||
test("evaluate - wildcard permission matches any permission", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", {
|
||||
"*": [{ pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission with specific pattern", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", {
|
||||
"*": [{ pattern: "rm", action: "deny" }],
|
||||
})
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - glob permission pattern", () => {
|
||||
const result = PermissionNext.evaluate("mcp_server_tool", "anything", {
|
||||
"mcp_*": [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - specific permission and wildcard permission combined", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", {
|
||||
"*": [{ pattern: "*", action: "deny" }],
|
||||
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", {
|
||||
"*": [{ pattern: "*", action: "deny" }],
|
||||
edit: [{ pattern: "src/*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching permission patterns combine rules", () => {
|
||||
const result = PermissionNext.evaluate("mcp_dangerous", "anything", {
|
||||
"*": [{ pattern: "*", action: "ask" }],
|
||||
"mcp_*": [{ pattern: "*", action: "allow" }],
|
||||
mcp_dangerous: [{ pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", {
|
||||
"*": [{ pattern: "*", action: "ask" }],
|
||||
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", {
|
||||
bash: [{ pattern: "*", action: "allow" }],
|
||||
"*": [{ pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
// disabledTools tests
|
||||
|
||||
test("disabledTools - returns empty set when all tools allowed", () => {
|
||||
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
|
||||
"*": [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabledTools - disables tool when denied", () => {
|
||||
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
|
||||
bash: [{ pattern: "*", action: "deny" }],
|
||||
"*": [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(false)
|
||||
expect(result.has("read")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabledTools - disables edit/write/patch/multiedit when edit denied", () => {
|
||||
const result = PermissionNext.disabledTools(["edit", "write", "patch", "multiedit", "bash"], {
|
||||
edit: [{ pattern: "*", action: "deny" }],
|
||||
"*": [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
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("disabledTools - does not disable when partially denied", () => {
|
||||
const result = PermissionNext.disabledTools(["bash"], {
|
||||
bash: [
|
||||
{ pattern: "*", action: "allow" },
|
||||
{ pattern: "rm *", action: "deny" },
|
||||
],
|
||||
})
|
||||
expect(result.has("bash")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabledTools - does not disable when action is ask", () => {
|
||||
const result = PermissionNext.disabledTools(["bash", "edit"], {
|
||||
"*": [{ pattern: "*", action: "ask" }],
|
||||
})
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabledTools - 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.disabledTools(["bash"], {
|
||||
bash: [
|
||||
{ pattern: "*", action: "deny" },
|
||||
{ pattern: "echo *", action: "allow" },
|
||||
],
|
||||
})
|
||||
expect(result.has("bash")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabledTools - does not disable when wildcard allow after deny", () => {
|
||||
const result = PermissionNext.disabledTools(["bash"], {
|
||||
bash: [
|
||||
{ pattern: "rm *", action: "deny" },
|
||||
{ pattern: "*", action: "allow" },
|
||||
],
|
||||
})
|
||||
expect(result.has("bash")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabledTools - disables multiple tools", () => {
|
||||
const result = PermissionNext.disabledTools(["bash", "edit", "webfetch"], {
|
||||
bash: [{ pattern: "*", action: "deny" }],
|
||||
edit: [{ pattern: "*", action: "deny" }],
|
||||
webfetch: [{ pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("webfetch")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabledTools - wildcard permission denies all tools", () => {
|
||||
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
|
||||
"*": [{ pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("read")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabledTools - specific allow overrides wildcard deny", () => {
|
||||
const result = PermissionNext.disabledTools(["bash", "edit", "read"], {
|
||||
"*": [{ pattern: "*", action: "deny" }],
|
||||
bash: [{ pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result.has("bash")).toBe(false)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("read")).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
|
||||
describe("ToolRegistry.enabled", () => {
|
||||
test("returns empty object when all tools allowed", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
edit: { "*": "allow" },
|
||||
bash: { "*": "allow" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("disables edit tools when edit is denied", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
edit: { "*": "deny" },
|
||||
bash: { "*": "allow" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
expect(result.edit).toBe(false)
|
||||
expect(result.write).toBe(false)
|
||||
expect(result.patch).toBe(false)
|
||||
expect(result.multiedit).toBe(false)
|
||||
})
|
||||
|
||||
test("disables specific tool when denied with wildcard", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
bash: { "*": "deny" },
|
||||
edit: { "*": "allow" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
expect(result.bash).toBe(false)
|
||||
})
|
||||
|
||||
test("does not disable tool when partially denied", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
bash: {
|
||||
"rm *": "deny",
|
||||
"*": "allow",
|
||||
},
|
||||
edit: { "*": "allow" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
expect(result.bash).toBeUndefined()
|
||||
})
|
||||
|
||||
test("disables multiple tools when multiple denied", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
edit: { "*": "deny" },
|
||||
bash: { "*": "deny" },
|
||||
webfetch: { "*": "deny" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
expect(result.edit).toBe(false)
|
||||
expect(result.write).toBe(false)
|
||||
expect(result.patch).toBe(false)
|
||||
expect(result.multiedit).toBe(false)
|
||||
expect(result.bash).toBe(false)
|
||||
expect(result.webfetch).toBe(false)
|
||||
})
|
||||
|
||||
test("does not disable tool when action is ask", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
edit: { "*": "ask" },
|
||||
bash: { "*": "ask" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
expect(result.edit).toBeUndefined()
|
||||
expect(result.bash).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not disable tool when wildcard deny has additional allow rules", async () => {
|
||||
const agent: Agent.Info = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
bash: {
|
||||
"*": "deny",
|
||||
"echo *": "allow",
|
||||
},
|
||||
edit: { "*": "allow" },
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
const result = await ToolRegistry.enabled(agent)
|
||||
// bash should NOT be disabled because there's an allow rule for "echo *"
|
||||
expect(result.bash).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -450,15 +450,18 @@ export type EventMessagePartRemoved = {
|
|||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
type: string
|
||||
patterns: Array<string>
|
||||
title: string
|
||||
description: string
|
||||
keys: Array<string>
|
||||
patterns?: Array<string>
|
||||
metadata: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
always: Array<string>
|
||||
permission: string
|
||||
}
|
||||
|
||||
export type EventPermissionRequest = {
|
||||
type: "permission.request"
|
||||
export type EventPermissionRequested = {
|
||||
type: "permission.requested"
|
||||
properties: PermissionRequest
|
||||
}
|
||||
|
||||
|
|
@ -492,40 +495,6 @@ export type EventPermissionReplied = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Todo = {
|
||||
/**
|
||||
* Brief description of the task
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* Current status of the task: pending, in_progress, completed, cancelled
|
||||
*/
|
||||
status: string
|
||||
/**
|
||||
* Priority level of the task: high, medium, low
|
||||
*/
|
||||
priority: string
|
||||
/**
|
||||
* Unique identifier for the todo item
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
|
||||
export type EventTodoUpdated = {
|
||||
type: "todo.updated"
|
||||
properties: {
|
||||
sessionID: string
|
||||
todos: Array<Todo>
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionStatus =
|
||||
| {
|
||||
type: "idle"
|
||||
|
|
@ -562,6 +531,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 EventCommandExecuted = {
|
||||
type: "command.executed"
|
||||
properties: {
|
||||
|
|
@ -760,14 +763,14 @@ export type Event =
|
|||
| EventMessageRemoved
|
||||
| EventMessagePartUpdated
|
||||
| EventMessagePartRemoved
|
||||
| EventPermissionRequest
|
||||
| EventPermissionRequested
|
||||
| EventPermissionUpdated
|
||||
| EventPermissionReplied
|
||||
| EventFileEdited
|
||||
| EventTodoUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSessionCompacted
|
||||
| EventFileEdited
|
||||
| EventTodoUpdated
|
||||
| EventCommandExecuted
|
||||
| EventSessionCreated
|
||||
| EventSessionUpdated
|
||||
|
|
@ -1159,6 +1162,7 @@ export type PermissionConfig = {
|
|||
external_directory?: PermissionRuleConfig
|
||||
todowrite?: PermissionActionConfig
|
||||
todoread?: PermissionActionConfig
|
||||
webfetch?: PermissionActionConfig
|
||||
websearch?: PermissionActionConfig
|
||||
codesearch?: PermissionActionConfig
|
||||
doom_loop?: PermissionActionConfig
|
||||
|
|
@ -1170,6 +1174,9 @@ export type AgentConfig = {
|
|||
temperature?: number
|
||||
top_p?: number
|
||||
prompt?: string
|
||||
/**
|
||||
* @deprecated Use 'permission' field instead
|
||||
*/
|
||||
tools?: {
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
|
@ -1179,6 +1186,9 @@ export type AgentConfig = {
|
|||
*/
|
||||
description?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
options?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
/**
|
||||
* Hex color code for the agent (e.g., #FF5733)
|
||||
*/
|
||||
|
|
@ -1186,6 +1196,10 @@ export type AgentConfig = {
|
|||
/**
|
||||
* Maximum number of agentic iterations before forcing text-only response
|
||||
*/
|
||||
steps?: number
|
||||
/**
|
||||
* @deprecated Use 'steps' field instead.
|
||||
*/
|
||||
maxSteps?: number
|
||||
permission?: PermissionConfig
|
||||
[key: string]:
|
||||
|
|
@ -1199,6 +1213,9 @@ export type AgentConfig = {
|
|||
| "subagent"
|
||||
| "primary"
|
||||
| "all"
|
||||
| {
|
||||
[key: string]: unknown
|
||||
}
|
||||
| string
|
||||
| number
|
||||
| PermissionConfig
|
||||
|
|
@ -1770,12 +1787,13 @@ export type File = {
|
|||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PermissionRule = {
|
||||
[key: string]: PermissionActionConfig
|
||||
}
|
||||
export type PermissionAction = "allow" | "deny" | "ask"
|
||||
|
||||
export type PermissionRuleset = {
|
||||
[key: string]: PermissionRule
|
||||
[key: string]: Array<{
|
||||
pattern: string
|
||||
action: PermissionAction
|
||||
}>
|
||||
}
|
||||
|
||||
export type Agent = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue