Permission rework (#6319)

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

View File

@@ -1,11 +1,16 @@
import { test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission/next"
test("loads built-in agents when no custom agents configured", async () => {
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
if (!agent) return undefined
return PermissionNext.evaluate(permission, "*", agent.permission)
}
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -14,133 +19,430 @@ test("loads built-in agents when no custom agents configured", async () => {
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
},
})
})
test("custom subagent works alongside built-in primary agents", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "helper.md"),
`---
model: test/model
mode: subagent
---
Helper subagent prompt`,
)
},
})
test("build agent has correct default properties", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const helper = agents.find((a) => a.name === "helper")
expect(helper).toBeDefined()
expect(helper?.mode).toBe("subagent")
// Built-in primary agents should still exist
const build = agents.find((a) => a.name === "build")
const build = await Agent.get("build")
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "bash")).toBe("allow")
},
})
})
test("throws error when all primary agents are disabled", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
build: { disable: true },
plan: { disable: true },
},
}),
)
},
})
test("plan agent denies edits except .opencode/plan/*", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
try {
await Agent.list()
expect(true).toBe(false) // should not reach here
} catch (e: any) {
expect(e.data?.message).toContain("No primary agents are available")
}
},
})
})
test("does not throw when at least one primary agent remains", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
build: { disable: true },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const plan = agents.find((a) => a.name === "plan")
const plan = await Agent.get("plan")
expect(plan).toBeDefined()
expect(plan?.mode).toBe("primary")
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow")
},
})
})
test("custom primary agent satisfies requirement when built-ins disabled", async () => {
test("explore agent denies edit and write", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
expect(explore?.mode).toBe("subagent")
expect(evalPerm(explore, "edit")).toBe("deny")
expect(evalPerm(explore, "write")).toBe("deny")
expect(evalPerm(explore, "todoread")).toBe("deny")
expect(evalPerm(explore, "todowrite")).toBe("deny")
},
})
})
test("general agent denies todo tools", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const general = await Agent.get("general")
expect(general).toBeDefined()
expect(general?.mode).toBe("subagent")
expect(general?.hidden).toBe(true)
expect(evalPerm(general, "todoread")).toBe("deny")
expect(evalPerm(general, "todowrite")).toBe("deny")
},
})
})
test("compaction agent denies all permissions", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const compaction = await Agent.get("compaction")
expect(compaction).toBeDefined()
expect(compaction?.hidden).toBe(true)
expect(evalPerm(compaction, "bash")).toBe("deny")
expect(evalPerm(compaction, "edit")).toBe("deny")
expect(evalPerm(compaction, "read")).toBe("deny")
},
})
})
test("custom agent from config creates new agent", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "custom.md"),
`---
model: test/model
mode: primary
---
Custom primary agent`,
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
build: { disable: true },
plan: { disable: true },
},
}),
)
config: {
agent: {
my_custom_agent: {
model: "openai/gpt-4",
description: "My custom agent",
temperature: 0.5,
top_p: 0.9,
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const custom = agents.find((a) => a.name === "custom")
const custom = await Agent.get("my_custom_agent")
expect(custom).toBeDefined()
expect(custom?.mode).toBe("primary")
expect(custom?.model?.providerID).toBe("openai")
expect(custom?.model?.modelID).toBe("gpt-4")
expect(custom?.description).toBe("My custom agent")
expect(custom?.temperature).toBe(0.5)
expect(custom?.topP).toBe(0.9)
expect(custom?.native).toBe(false)
expect(custom?.mode).toBe("all")
},
})
})
test("custom agent config overrides native agent properties", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
model: "anthropic/claude-3",
description: "Custom build agent",
temperature: 0.7,
color: "#FF0000",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
expect(build?.model?.providerID).toBe("anthropic")
expect(build?.model?.modelID).toBe("claude-3")
expect(build?.description).toBe("Custom build agent")
expect(build?.temperature).toBe(0.7)
expect(build?.color).toBe("#FF0000")
expect(build?.native).toBe(true)
},
})
})
test("agent disable removes agent from list", async () => {
await using tmp = await tmpdir({
config: {
agent: {
explore: { disable: true },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeUndefined()
const agents = await Agent.list()
const names = agents.map((a) => a.name)
expect(names).not.toContain("explore")
},
})
})
test("agent permission config merges with defaults", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
permission: {
bash: {
"rm -rf *": "deny",
},
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
},
})
})
test("global permission config applies to all agents", async () => {
await using tmp = await tmpdir({
config: {
permission: {
bash: "deny",
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
expect(evalPerm(build, "bash")).toBe("deny")
},
})
})
test("agent steps/maxSteps config sets steps property", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: { steps: 50 },
plan: { maxSteps: 100 },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const plan = await Agent.get("plan")
expect(build?.steps).toBe(50)
expect(plan?.steps).toBe(100)
},
})
})
test("agent mode can be overridden", async () => {
await using tmp = await tmpdir({
config: {
agent: {
explore: { mode: "primary" },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
expect(explore?.mode).toBe("primary")
},
})
})
test("agent name can be overridden", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: { name: "Builder" },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.name).toBe("Builder")
},
})
})
test("agent prompt can be set from config", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: { prompt: "Custom system prompt" },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.prompt).toBe("Custom system prompt")
},
})
})
test("unknown agent properties are placed into options", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
random_property: "hello",
another_random: 123,
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.options.random_property).toBe("hello")
expect(build?.options.another_random).toBe(123)
},
})
})
test("agent options merge correctly", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
options: {
custom_option: true,
another_option: "value",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.options.custom_option).toBe(true)
expect(build?.options.another_option).toBe("value")
},
})
})
test("multiple custom agents can be defined", async () => {
await using tmp = await tmpdir({
config: {
agent: {
agent_a: {
description: "Agent A",
mode: "subagent",
},
agent_b: {
description: "Agent B",
mode: "primary",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agentA = await Agent.get("agent_a")
const agentB = await Agent.get("agent_b")
expect(agentA?.description).toBe("Agent A")
expect(agentA?.mode).toBe("subagent")
expect(agentB?.description).toBe("Agent B")
expect(agentB?.mode).toBe("primary")
},
})
})
test("Agent.get returns undefined for non-existent agent", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nonExistent = await Agent.get("does_not_exist")
expect(nonExistent).toBeUndefined()
},
})
})
test("default permission includes doom_loop and external_directory as ask", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(evalPerm(build, "doom_loop")).toBe("ask")
expect(evalPerm(build, "external_directory")).toBe("ask")
},
})
})
test("webfetch is allowed by default", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(evalPerm(build, "webfetch")).toBe("allow")
},
})
})
test("legacy tools config converts to permissions", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
tools: {
bash: false,
read: false,
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(evalPerm(build, "bash")).toBe("deny")
expect(evalPerm(build, "read")).toBe("deny")
},
})
})
test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
tools: {
write: false,
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(evalPerm(build, "edit")).toBe("deny")
},
})
})

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { test, expect } from "bun:test"
import { BashArity } from "../../src/permission/arity"
test("arity 1 - unknown commands default to first token", () => {
expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"])
expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"])
})
test("arity 2 - two token commands", () => {
expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"])
expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"])
})
test("arity 3 - three token commands", () => {
expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"])
expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"])
})
test("longest match wins - nested prefixes", () => {
expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"])
expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"])
})
test("exact length matches", () => {
expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"])
expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"])
})
test("edge cases", () => {
expect(BashArity.prefix([])).toEqual([])
expect(BashArity.prefix(["single"])).toEqual(["single"])
expect(BashArity.prefix(["git"])).toEqual(["git"])
})

View File

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

View File

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

View File

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

View File

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

View File

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