mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 16:36:52 +00:00
Permission rework (#6319)
Co-authored-by: Github Action <action@github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
|
||||
test("loads built-in agents when no custom agents configured", async () => {
|
||||
// Helper to evaluate permission for a tool with wildcard pattern
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
||||
if (!agent) return undefined
|
||||
return PermissionNext.evaluate(permission, "*", agent.permission)
|
||||
}
|
||||
|
||||
test("returns default native agents when no config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
@@ -14,133 +19,430 @@ test("loads built-in agents when no custom agents configured", async () => {
|
||||
const names = agents.map((a) => a.name)
|
||||
expect(names).toContain("build")
|
||||
expect(names).toContain("plan")
|
||||
expect(names).toContain("general")
|
||||
expect(names).toContain("explore")
|
||||
expect(names).toContain("compaction")
|
||||
expect(names).toContain("title")
|
||||
expect(names).toContain("summary")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom subagent works alongside built-in primary agents", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
const agentDir = path.join(opencodeDir, "agent")
|
||||
await fs.mkdir(agentDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(agentDir, "helper.md"),
|
||||
`---
|
||||
model: test/model
|
||||
mode: subagent
|
||||
---
|
||||
Helper subagent prompt`,
|
||||
)
|
||||
},
|
||||
})
|
||||
test("build agent has correct default properties", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await Agent.list()
|
||||
const helper = agents.find((a) => a.name === "helper")
|
||||
expect(helper).toBeDefined()
|
||||
expect(helper?.mode).toBe("subagent")
|
||||
|
||||
// Built-in primary agents should still exist
|
||||
const build = agents.find((a) => a.name === "build")
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when all primary agents are disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
build: { disable: true },
|
||||
plan: { disable: true },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
test("plan agent denies edits except .opencode/plan/*", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
try {
|
||||
await Agent.list()
|
||||
expect(true).toBe(false) // should not reach here
|
||||
} catch (e: any) {
|
||||
expect(e.data?.message).toContain("No primary agents are available")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not throw when at least one primary agent remains", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
build: { disable: true },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await Agent.list()
|
||||
const plan = agents.find((a) => a.name === "plan")
|
||||
const plan = await Agent.get("plan")
|
||||
expect(plan).toBeDefined()
|
||||
expect(plan?.mode).toBe("primary")
|
||||
// Wildcard is denied
|
||||
expect(evalPerm(plan, "edit")).toBe("deny")
|
||||
// But specific path is allowed
|
||||
expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom primary agent satisfies requirement when built-ins disabled", async () => {
|
||||
test("explore agent denies edit and write", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await Agent.get("explore")
|
||||
expect(explore).toBeDefined()
|
||||
expect(explore?.mode).toBe("subagent")
|
||||
expect(evalPerm(explore, "edit")).toBe("deny")
|
||||
expect(evalPerm(explore, "write")).toBe("deny")
|
||||
expect(evalPerm(explore, "todoread")).toBe("deny")
|
||||
expect(evalPerm(explore, "todowrite")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("general agent denies todo tools", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const general = await Agent.get("general")
|
||||
expect(general).toBeDefined()
|
||||
expect(general?.mode).toBe("subagent")
|
||||
expect(general?.hidden).toBe(true)
|
||||
expect(evalPerm(general, "todoread")).toBe("deny")
|
||||
expect(evalPerm(general, "todowrite")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("compaction agent denies all permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const compaction = await Agent.get("compaction")
|
||||
expect(compaction).toBeDefined()
|
||||
expect(compaction?.hidden).toBe(true)
|
||||
expect(evalPerm(compaction, "bash")).toBe("deny")
|
||||
expect(evalPerm(compaction, "edit")).toBe("deny")
|
||||
expect(evalPerm(compaction, "read")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom agent from config creates new agent", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
const agentDir = path.join(opencodeDir, "agent")
|
||||
await fs.mkdir(agentDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(agentDir, "custom.md"),
|
||||
`---
|
||||
model: test/model
|
||||
mode: primary
|
||||
---
|
||||
Custom primary agent`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
build: { disable: true },
|
||||
plan: { disable: true },
|
||||
},
|
||||
}),
|
||||
)
|
||||
config: {
|
||||
agent: {
|
||||
my_custom_agent: {
|
||||
model: "openai/gpt-4",
|
||||
description: "My custom agent",
|
||||
temperature: 0.5,
|
||||
top_p: 0.9,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await Agent.list()
|
||||
const custom = agents.find((a) => a.name === "custom")
|
||||
const custom = await Agent.get("my_custom_agent")
|
||||
expect(custom).toBeDefined()
|
||||
expect(custom?.mode).toBe("primary")
|
||||
expect(custom?.model?.providerID).toBe("openai")
|
||||
expect(custom?.model?.modelID).toBe("gpt-4")
|
||||
expect(custom?.description).toBe("My custom agent")
|
||||
expect(custom?.temperature).toBe(0.5)
|
||||
expect(custom?.topP).toBe(0.9)
|
||||
expect(custom?.native).toBe(false)
|
||||
expect(custom?.mode).toBe("all")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom agent config overrides native agent properties", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
model: "anthropic/claude-3",
|
||||
description: "Custom build agent",
|
||||
temperature: 0.7,
|
||||
color: "#FF0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.model?.providerID).toBe("anthropic")
|
||||
expect(build?.model?.modelID).toBe("claude-3")
|
||||
expect(build?.description).toBe("Custom build agent")
|
||||
expect(build?.temperature).toBe(0.7)
|
||||
expect(build?.color).toBe("#FF0000")
|
||||
expect(build?.native).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent disable removes agent from list", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
explore: { disable: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await Agent.get("explore")
|
||||
expect(explore).toBeUndefined()
|
||||
const agents = await Agent.list()
|
||||
const names = agents.map((a) => a.name)
|
||||
expect(names).not.toContain("explore")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent permission config merges with defaults", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
permission: {
|
||||
bash: {
|
||||
"rm -rf *": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("global permission config applies to all agents", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
permission: {
|
||||
bash: "deny",
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
expect(evalPerm(build, "bash")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent steps/maxSteps config sets steps property", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: { steps: 50 },
|
||||
plan: { maxSteps: 100 },
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
const plan = await Agent.get("plan")
|
||||
expect(build?.steps).toBe(50)
|
||||
expect(plan?.steps).toBe(100)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent mode can be overridden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
explore: { mode: "primary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await Agent.get("explore")
|
||||
expect(explore?.mode).toBe("primary")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent name can be overridden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: { name: "Builder" },
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.name).toBe("Builder")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent prompt can be set from config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: { prompt: "Custom system prompt" },
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.prompt).toBe("Custom system prompt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("unknown agent properties are placed into options", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
random_property: "hello",
|
||||
another_random: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.options.random_property).toBe("hello")
|
||||
expect(build?.options.another_random).toBe(123)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("agent options merge correctly", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
options: {
|
||||
custom_option: true,
|
||||
another_option: "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build?.options.custom_option).toBe(true)
|
||||
expect(build?.options.another_option).toBe("value")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("multiple custom agents can be defined", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
agent_a: {
|
||||
description: "Agent A",
|
||||
mode: "subagent",
|
||||
},
|
||||
agent_b: {
|
||||
description: "Agent B",
|
||||
mode: "primary",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agentA = await Agent.get("agent_a")
|
||||
const agentB = await Agent.get("agent_b")
|
||||
expect(agentA?.description).toBe("Agent A")
|
||||
expect(agentA?.mode).toBe("subagent")
|
||||
expect(agentB?.description).toBe("Agent B")
|
||||
expect(agentB?.mode).toBe("primary")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Agent.get returns undefined for non-existent agent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistent = await Agent.get("does_not_exist")
|
||||
expect(nonExistent).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("default permission includes doom_loop and external_directory as ask", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(evalPerm(build, "doom_loop")).toBe("ask")
|
||||
expect(evalPerm(build, "external_directory")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("webfetch is allowed by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(evalPerm(build, "webfetch")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("legacy tools config converts to permissions", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
tools: {
|
||||
bash: false,
|
||||
read: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(evalPerm(build, "bash")).toBe("deny")
|
||||
expect(evalPerm(build, "read")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
tools: {
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(evalPerm(build, "edit")).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,11 +205,13 @@ test("handles agent configuration", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test_agent"]).toEqual({
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
})
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -292,6 +294,8 @@ test("migrates mode field to agent field", async () => {
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -318,11 +322,13 @@ Test agent prompt`,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]).toEqual({
|
||||
name: "test",
|
||||
model: "test/model",
|
||||
prompt: "Test agent prompt",
|
||||
})
|
||||
expect(config.agent?.["test"]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "test",
|
||||
model: "test/model",
|
||||
prompt: "Test agent prompt",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -472,7 +478,7 @@ Helper subagent prompt`,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["helper"]).toEqual({
|
||||
expect(config.agent?.["helper"]).toMatchObject({
|
||||
name: "helper",
|
||||
model: "test/model",
|
||||
mode: "subagent",
|
||||
@@ -534,36 +540,22 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
})
|
||||
})
|
||||
|
||||
test("compaction config defaults to true when not specified", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
// When not specified, compaction should be undefined (defaults handled in usage)
|
||||
expect(config.compaction).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
// Legacy tools migration tests
|
||||
|
||||
test("compaction config can disable auto compaction", async () => {
|
||||
test("migrates legacy tools config to permissions - allow", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
compaction: {
|
||||
auto: false,
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
bash: true,
|
||||
read: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -573,21 +565,28 @@ test("compaction config can disable auto compaction", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.compaction?.auto).toBe(false)
|
||||
expect(config.compaction?.prune).toBeUndefined()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "allow",
|
||||
read: "allow",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("compaction config can disable prune", async () => {
|
||||
test("migrates legacy tools config to permissions - deny", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
compaction: {
|
||||
prune: false,
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
bash: false,
|
||||
webfetch: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -597,22 +596,27 @@ test("compaction config can disable prune", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.compaction?.prune).toBe(false)
|
||||
expect(config.compaction?.auto).toBeUndefined()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "deny",
|
||||
webfetch: "deny",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("compaction config can disable both auto and prune", async () => {
|
||||
test("migrates legacy write tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
compaction: {
|
||||
auto: false,
|
||||
prune: false,
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -622,8 +626,164 @@ test("compaction config can disable both auto and prune", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.compaction?.auto).toBe(false)
|
||||
expect(config.compaction?.prune).toBe(false)
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "allow",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates legacy edit tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "deny",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates legacy patch tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
patch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "allow",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates legacy multiedit tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
multiedit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "deny",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates mixed legacy tools config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test: {
|
||||
tools: {
|
||||
bash: true,
|
||||
write: true,
|
||||
read: false,
|
||||
webfetch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "allow",
|
||||
edit: "allow",
|
||||
read: "deny",
|
||||
webfetch: "allow",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("merges legacy tools with existing permission config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test: {
|
||||
permission: {
|
||||
glob: "allow",
|
||||
},
|
||||
tools: {
|
||||
bash: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
glob: "allow",
|
||||
bash: "allow",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { $ } from "bun"
|
||||
import * as fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Config } from "../../src/config/config"
|
||||
|
||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||
function sanitizePath(p: string): string {
|
||||
@@ -10,6 +11,7 @@ function sanitizePath(p: string): string {
|
||||
|
||||
type TmpDirOptions<T> = {
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info>
|
||||
init?: (dir: string) => Promise<T>
|
||||
dispose?: (dir: string) => Promise<T>
|
||||
}
|
||||
@@ -20,6 +22,15 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
||||
await $`git init`.cwd(dirpath).quiet()
|
||||
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
|
||||
}
|
||||
if (options?.config) {
|
||||
await Bun.write(
|
||||
path.join(dirpath, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
...options.config,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const extra = await options?.init?.(dirpath)
|
||||
const realpath = sanitizePath(await fs.realpath(dirpath))
|
||||
const result = {
|
||||
|
||||
33
packages/opencode/test/permission/arity.test.ts
Normal file
33
packages/opencode/test/permission/arity.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { BashArity } from "../../src/permission/arity"
|
||||
|
||||
test("arity 1 - unknown commands default to first token", () => {
|
||||
expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"])
|
||||
expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"])
|
||||
})
|
||||
|
||||
test("arity 2 - two token commands", () => {
|
||||
expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"])
|
||||
expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"])
|
||||
})
|
||||
|
||||
test("arity 3 - three token commands", () => {
|
||||
expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"])
|
||||
expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"])
|
||||
})
|
||||
|
||||
test("longest match wins - nested prefixes", () => {
|
||||
expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"])
|
||||
expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"])
|
||||
})
|
||||
|
||||
test("exact length matches", () => {
|
||||
expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"])
|
||||
expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"])
|
||||
})
|
||||
|
||||
test("edge cases", () => {
|
||||
expect(BashArity.prefix([])).toEqual([])
|
||||
expect(BashArity.prefix(["single"])).toEqual(["single"])
|
||||
expect(BashArity.prefix(["git"])).toEqual(["git"])
|
||||
})
|
||||
652
packages/opencode/test/permission/next.test.ts
Normal file
652
packages/opencode/test/permission/next.test.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Storage } from "../../src/storage/storage"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
// fromConfig tests
|
||||
|
||||
test("fromConfig - string value becomes wildcard rule", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: "allow" })
|
||||
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - object value converts to rules array", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
])
|
||||
})
|
||||
|
||||
test("fromConfig - mixed string and object values", () => {
|
||||
const result = PermissionNext.fromConfig({
|
||||
bash: { "*": "allow", rm: "deny" },
|
||||
edit: "allow",
|
||||
webfetch: "ask",
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
{ permission: "edit", pattern: "*", action: "allow" },
|
||||
{ permission: "webfetch", pattern: "*", action: "ask" },
|
||||
])
|
||||
})
|
||||
|
||||
test("fromConfig - empty object", () => {
|
||||
const result = PermissionNext.fromConfig({})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// merge tests
|
||||
|
||||
test("merge - simple concatenation", () => {
|
||||
const result = PermissionNext.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
])
|
||||
})
|
||||
|
||||
test("merge - adds new permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
])
|
||||
})
|
||||
|
||||
test("merge - concatenates rules for same permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
[{ permission: "bash", pattern: "foo", action: "ask" }],
|
||||
[{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "foo", action: "ask" },
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
])
|
||||
})
|
||||
|
||||
test("merge - multiple rulesets", () => {
|
||||
const result = PermissionNext.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "bash", pattern: "rm", action: "ask" }],
|
||||
[{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "ask" },
|
||||
{ permission: "edit", pattern: "*", action: "allow" },
|
||||
])
|
||||
})
|
||||
|
||||
test("merge - empty ruleset does nothing", () => {
|
||||
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
|
||||
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
})
|
||||
|
||||
test("merge - preserves rule order", () => {
|
||||
const result = PermissionNext.merge(
|
||||
[
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
|
||||
],
|
||||
[{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
|
||||
])
|
||||
})
|
||||
|
||||
test("merge - config permission overrides default ask", () => {
|
||||
// Simulates: defaults have "*": "ask", config sets bash: "allow"
|
||||
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const merged = PermissionNext.merge(defaults, config)
|
||||
|
||||
// Config's bash allow should override default ask
|
||||
expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow")
|
||||
// Other permissions should still be ask (from defaults)
|
||||
expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask")
|
||||
})
|
||||
|
||||
test("merge - config ask overrides default allow", () => {
|
||||
// Simulates: defaults have bash: "allow", config sets bash: "ask"
|
||||
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
|
||||
const merged = PermissionNext.merge(defaults, config)
|
||||
|
||||
// Config's ask should override default allow
|
||||
expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask")
|
||||
})
|
||||
|
||||
// evaluate tests
|
||||
|
||||
test("evaluate - exact pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - glob pattern match", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching glob wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - order matters for specificity", () => {
|
||||
// If more specific rule comes first, later wildcard overrides it
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - unknown permission returns ask", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - empty ruleset returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [])
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - no matching pattern returns ask", () => {
|
||||
const result = PermissionNext.evaluate("edit", "etc/passwd", [
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - empty rules array returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [])
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching patterns, last wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/secret.ts", [
|
||||
{ permission: "edit", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
|
||||
])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - non-matching patterns are skipped", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "edit", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "test/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
// wildcard permission tests
|
||||
|
||||
test("evaluate - wildcard permission matches any permission", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission with specific pattern", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - glob permission pattern", () => {
|
||||
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
|
||||
{ permission: "mcp_*", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - specific permission and wildcard permission combined", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission does not match when specific exists", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching permission patterns combine rules", () => {
|
||||
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
|
||||
{ permission: "*", pattern: "*", action: "ask" },
|
||||
{ permission: "mcp_*", pattern: "*", action: "allow" },
|
||||
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
|
||||
])
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", [
|
||||
{ permission: "*", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - permission patterns sorted by length regardless of object order", () => {
|
||||
// specific permission listed before wildcard, but specific should still win
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
])
|
||||
// With flat list, last matching rule wins - so "*" matches bash and wins
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - merges multiple rulesets", () => {
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
|
||||
// approved comes after config, so rm should be denied
|
||||
const result = PermissionNext.evaluate("bash", "rm", config, approved)
|
||||
expect(result).toBe("deny")
|
||||
})
|
||||
|
||||
// disabled tests
|
||||
|
||||
test("disabled - returns empty set when all tools allowed", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabled - disables tool when denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["bash", "edit", "read"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
],
|
||||
)
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(false)
|
||||
expect(result.has("read")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["edit", "write", "patch", "multiedit", "bash"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
],
|
||||
)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("write")).toBe(true)
|
||||
expect(result.has("patch")).toBe(true)
|
||||
expect(result.has("multiedit")).toBe(true)
|
||||
expect(result.has("bash")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabled - does not disable when partially denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
],
|
||||
)
|
||||
expect(result.has("bash")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabled - does not disable when action is ask", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabled - disables when wildcard deny even with specific allow", () => {
|
||||
// Tool is disabled because evaluate("bash", "*", ...) returns "deny"
|
||||
// The "echo *" allow rule doesn't match the "*" pattern we're checking
|
||||
const result = PermissionNext.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
{ permission: "bash", pattern: "echo *", action: "allow" },
|
||||
],
|
||||
)
|
||||
expect(result.has("bash")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabled - does not disable when wildcard allow after deny", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
],
|
||||
)
|
||||
expect(result.has("bash")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabled - disables multiple tools", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["bash", "edit", "webfetch"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
{ permission: "webfetch", pattern: "*", action: "deny" },
|
||||
],
|
||||
)
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("webfetch")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabled - wildcard permission denies all tools", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("read")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabled - specific allow overrides wildcard deny", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["bash", "edit", "read"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
],
|
||||
)
|
||||
expect(result.has("bash")).toBe(false)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("read")).toBe(true)
|
||||
})
|
||||
|
||||
// ask tests
|
||||
|
||||
test("ask - resolves immediately when action is allow", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await PermissionNext.ask({
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ask - throws RejectedError when action is deny", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
PermissionNext.ask({
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["rm -rf /"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PermissionNext.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ask - returns pending promise when action is ask", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = PermissionNext.ask({
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
})
|
||||
// Promise should be pending, not resolved
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
// Don't await - just verify it returns a promise
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// reply tests
|
||||
|
||||
test("reply - once resolves the pending ask", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
id: "permission_test1",
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
await PermissionNext.reply({
|
||||
requestID: "permission_test1",
|
||||
reply: "once",
|
||||
})
|
||||
|
||||
await expect(askPromise).resolves.toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - reject throws RejectedError", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
id: "permission_test2",
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
await PermissionNext.reply({
|
||||
requestID: "permission_test2",
|
||||
reply: "reject",
|
||||
})
|
||||
|
||||
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - always persists approval and resolves", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
id: "permission_test3",
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: ["ls"],
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
await PermissionNext.reply({
|
||||
requestID: "permission_test3",
|
||||
reply: "always",
|
||||
})
|
||||
|
||||
await expect(askPromise).resolves.toBeUndefined()
|
||||
},
|
||||
})
|
||||
// Re-provide to reload state with stored permissions
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Stored approval should allow without asking
|
||||
const result = await PermissionNext.ask({
|
||||
sessionID: "session_test2",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - reject cancels all pending for same session", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise1 = PermissionNext.ask({
|
||||
id: "permission_test4a",
|
||||
sessionID: "session_same",
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const askPromise2 = PermissionNext.ask({
|
||||
id: "permission_test4b",
|
||||
sessionID: "session_same",
|
||||
permission: "edit",
|
||||
patterns: ["foo.ts"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
// Catch rejections before they become unhandled
|
||||
const result1 = askPromise1.catch((e) => e)
|
||||
const result2 = askPromise2.catch((e) => e)
|
||||
|
||||
// Reject the first one
|
||||
await PermissionNext.reply({
|
||||
requestID: "permission_test4a",
|
||||
reply: "reject",
|
||||
})
|
||||
|
||||
// Both should be rejected
|
||||
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
|
||||
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ask - checks all patterns and stops on first deny", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
PermissionNext.ask({
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PermissionNext.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ask - allows all patterns when all match allow rules", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await PermissionNext.ask({
|
||||
sessionID: "session_test",
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "ls -la", "pwd"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
@@ -12,6 +12,7 @@ const ctx = {
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
@@ -37,397 +38,164 @@ describe("tool.bash", () => {
|
||||
})
|
||||
|
||||
describe("tool.bash permissions", () => {
|
||||
test("allows command matching allow pattern", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"echo *": "allow",
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
test("asks for bash permission with correct pattern", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
ctx,
|
||||
testCtx,
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.metadata.output).toContain("hello")
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
expect(requests[0].patterns).toContain("echo hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("denies command matching deny pattern", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"curl *": "deny",
|
||||
"*": "allow",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
test("asks for bash permission with multiple commands", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "curl https://example.com",
|
||||
description: "Fetch URL",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("denies all commands with wildcard deny", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "ls",
|
||||
description: "List files",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("more specific pattern overrides general pattern", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"*": "deny",
|
||||
"ls *": "allow",
|
||||
"pwd*": "allow",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
// ls should be allowed
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "ls -la",
|
||||
description: "List files",
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
|
||||
// pwd should be allowed
|
||||
const pwd = await bash.execute(
|
||||
{
|
||||
command: "pwd",
|
||||
description: "Print working directory",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(pwd.metadata.exit).toBe(0)
|
||||
|
||||
// cat should be denied
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cat /etc/passwd",
|
||||
description: "Read file",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("denies dangerous subcommands while allowing safe ones", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"find *": "allow",
|
||||
"find * -delete*": "deny",
|
||||
"find * -exec*": "deny",
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
// Basic find should work
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "find . -name '*.ts'",
|
||||
description: "Find typescript files",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
|
||||
// find -delete should be denied
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "find . -name '*.tmp' -delete",
|
||||
description: "Delete temp files",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
|
||||
// find -exec should be denied
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "find . -name '*.ts' -exec cat {} \\;",
|
||||
description: "Find and cat files",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("allows git read commands while denying writes", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"git status*": "allow",
|
||||
"git log*": "allow",
|
||||
"git diff*": "allow",
|
||||
"git branch": "allow",
|
||||
"git commit *": "deny",
|
||||
"git push *": "deny",
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
// git status should work
|
||||
const status = await bash.execute(
|
||||
{
|
||||
command: "git status",
|
||||
description: "Git status",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(status.metadata.exit).toBe(0)
|
||||
|
||||
// git log should work
|
||||
const log = await bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(log.metadata.exit).toBe(0)
|
||||
|
||||
// git commit should be denied
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "git commit -m 'test'",
|
||||
description: "Git commit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
|
||||
// git push should be denied
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "git push origin main",
|
||||
description: "Git push",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("denies external directory access when permission is deny", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "deny",
|
||||
bash: {
|
||||
"*": "allow",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
// Should deny cd to parent directory (cd is checked for external paths)
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("denies workdir outside project when external_directory is deny", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "deny",
|
||||
bash: {
|
||||
"*": "allow",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "ls",
|
||||
workdir: "/tmp",
|
||||
description: "List /tmp",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles multiple commands in sequence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
bash: {
|
||||
"echo *": "allow",
|
||||
"curl *": "deny",
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
// echo && echo should work
|
||||
const result = await bash.execute(
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
ctx,
|
||||
testCtx,
|
||||
)
|
||||
expect(result.metadata.output).toContain("foo")
|
||||
expect(result.metadata.output).toContain("bar")
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
expect(requests[0].patterns).toContain("echo foo")
|
||||
expect(requests[0].patterns).toContain("echo bar")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// echo && curl should fail (curl is denied)
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo hi && curl https://example.com",
|
||||
description: "Echo then curl",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("restricted")
|
||||
test("asks for external_directory permission when cd to parent", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for external_directory permission when workdir is outside project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "ls",
|
||||
workdir: "/tmp",
|
||||
description: "List /tmp",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain("/tmp")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("includes always patterns for auto-approval", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].always.length).toBeGreaterThan(0)
|
||||
expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not ask for bash permission when command is cd only", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const ctx = {
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
|
||||
@@ -3,16 +3,17 @@ import path from "path"
|
||||
import { PatchTool } from "../../src/tool/patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import * as fs from "fs/promises"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const patchTool = await PatchTool.init()
|
||||
@@ -59,7 +60,8 @@ describe("tool.patch", () => {
|
||||
patchTool.execute({ patchText: maliciousPatch }, ctx)
|
||||
// TODO: this sucks
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
expect(Permission.pending()[ctx.sessionID]).toBeDefined()
|
||||
const pending = await PermissionNext.list()
|
||||
expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import { ReadTool } from "../../src/tool/read"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
@@ -11,6 +12,7 @@ const ctx = {
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
describe("tool.read external_directory permission", () => {
|
||||
@@ -18,14 +20,6 @@ describe("tool.read external_directory permission", () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), "hello world")
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "deny",
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
@@ -42,14 +36,6 @@ describe("tool.read external_directory permission", () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "deny",
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
@@ -62,83 +48,74 @@ describe("tool.read external_directory permission", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("denies reading absolute path outside project directory", async () => {
|
||||
test("asks for external_directory permission when reading absolute path outside project", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "secret.txt"), "secret data")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "deny",
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow(
|
||||
"not in the current working directory",
|
||||
)
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("denies reading relative path that traverses outside project directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "deny",
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
test("asks for external_directory permission when reading relative path outside project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow(
|
||||
"not in the current working directory",
|
||||
)
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
// This will fail because file doesn't exist, but we can check if permission was asked
|
||||
await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("allows reading outside project directory when external_directory is allow", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "external.txt"), "external content")
|
||||
},
|
||||
})
|
||||
test("does not ask for external_directory permission when reading inside project", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
permission: {
|
||||
external_directory: "allow",
|
||||
},
|
||||
}),
|
||||
)
|
||||
await Bun.write(path.join(dir, "internal.txt"), "internal content")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx)
|
||||
expect(result.output).toContain("external content")
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user