| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- import { describe, test, expect } from "bun:test"
- import { PermissionNext } from "../src/permission/next"
- import { Config } from "../src/config/config"
- import { Instance } from "../src/project/instance"
- import { tmpdir } from "./fixture/fixture"
- describe("PermissionNext.evaluate for permission.task", () => {
- const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
- Object.entries(rules).map(([pattern, action]) => ({
- permission: "task",
- pattern,
- action,
- }))
- test("returns ask when no match (default)", () => {
- expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
- })
- test("returns deny for explicit deny", () => {
- const ruleset = createRuleset({ "code-reviewer": "deny" })
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- })
- test("returns allow for explicit allow", () => {
- const ruleset = createRuleset({ "code-reviewer": "allow" })
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
- })
- test("returns ask for explicit ask", () => {
- const ruleset = createRuleset({ "code-reviewer": "ask" })
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
- })
- test("matches wildcard patterns with deny", () => {
- const ruleset = createRuleset({ "orchestrator-*": "deny" })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
- })
- test("matches wildcard patterns with allow", () => {
- const ruleset = createRuleset({ "orchestrator-*": "allow" })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
- })
- test("matches wildcard patterns with ask", () => {
- const ruleset = createRuleset({ "orchestrator-*": "ask" })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
- const globalRuleset = createRuleset({ "*": "ask" })
- expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
- })
- test("later rules take precedence (last match wins)", () => {
- const ruleset = createRuleset({
- "orchestrator-*": "deny",
- "orchestrator-fast": "allow",
- })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
- })
- test("matches global wildcard", () => {
- expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
- })
- })
- describe("PermissionNext.disabled for task tool", () => {
- // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
- // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
- // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
- const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
- Object.entries(rules).map(([pattern, action]) => ({
- permission: "task",
- pattern,
- action,
- }))
- test("task tool is disabled when global deny pattern exists (even with specific allows)", () => {
- // When "*": "deny" exists, the task tool is disabled because the disabled() function
- // only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed
- const ruleset = createRuleset({
- "orchestrator-*": "allow",
- "*": "deny",
- })
- const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
- // The task tool IS disabled because there's a pattern: "*" with action: "deny"
- expect(disabled.has("task")).toBe(true)
- })
- test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => {
- const ruleset = createRuleset({
- "orchestrator-*": "ask",
- "*": "deny",
- })
- const disabled = PermissionNext.disabled(["task"], ruleset)
- // The task tool IS disabled because there's a pattern: "*" with action: "deny"
- expect(disabled.has("task")).toBe(true)
- })
- test("task tool is disabled when global deny pattern exists", () => {
- const ruleset = createRuleset({ "*": "deny" })
- const disabled = PermissionNext.disabled(["task"], ruleset)
- expect(disabled.has("task")).toBe(true)
- })
- test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => {
- // The disabled() function only disables tools when pattern: "*" && action: "deny"
- // Specific subagent denies don't disable the task tool - those are handled at runtime
- const ruleset = createRuleset({
- "orchestrator-*": "deny",
- general: "deny",
- })
- const disabled = PermissionNext.disabled(["task"], ruleset)
- // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
- expect(disabled.has("task")).toBe(false)
- })
- test("task tool is enabled when no task rules exist (default ask)", () => {
- const disabled = PermissionNext.disabled(["task"], [])
- expect(disabled.has("task")).toBe(false)
- })
- test("task tool is NOT disabled when last wildcard pattern is allow", () => {
- // Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled
- const ruleset = createRuleset({
- "*": "deny",
- "orchestrator-coder": "allow",
- })
- const disabled = PermissionNext.disabled(["task"], ruleset)
- // The disabled() function uses findLast and checks if the last matching rule
- // has pattern: "*" and action: "deny". In this case, the last rule matching
- // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
- expect(disabled.has("task")).toBe(false)
- })
- })
- // Integration tests that load permissions from real config files
- describe("permission.task with real config files", () => {
- test("loads task permissions from opencode.json config", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- permission: {
- task: {
- "*": "allow",
- "code-reviewer": "deny",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- // general and orchestrator-fast should be allowed, code-reviewer denied
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- },
- })
- })
- test("loads task permissions with wildcard patterns from config", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- permission: {
- task: {
- "*": "ask",
- "orchestrator-*": "deny",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- // general and code-reviewer should be ask, orchestrator-* denied
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
- },
- })
- })
- test("evaluate respects task permission from config", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- permission: {
- task: {
- general: "allow",
- "code-reviewer": "deny",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- // Unspecified agents default to "ask"
- expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
- },
- })
- })
- test("mixed permission config with task and other tools", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- permission: {
- bash: "allow",
- edit: "ask",
- task: {
- "*": "deny",
- general: "allow",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- // Verify task permissions
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- // Verify other tool permissions
- expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
- // Verify disabled tools
- const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
- expect(disabled.has("bash")).toBe(false)
- expect(disabled.has("edit")).toBe(false)
- // task is NOT disabled because disabled() uses findLast, and the last rule
- // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
- expect(disabled.has("task")).toBe(false)
- },
- })
- })
- test("task tool disabled when global deny comes last in config", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- permission: {
- task: {
- general: "allow",
- "code-reviewer": "allow",
- "*": "deny",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- // Last matching rule wins - "*" deny is last, so all agents are denied
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
- // Since "*": "deny" is the last rule, disabled() finds it with findLast
- // and sees pattern: "*" with action: "deny", so task is disabled
- const disabled = PermissionNext.disabled(["task"], ruleset)
- expect(disabled.has("task")).toBe(true)
- },
- })
- })
- test("task tool NOT disabled when specific allow comes last in config", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- permission: {
- task: {
- "*": "deny",
- general: "allow",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- // Evaluate uses findLast - "general" allow comes after "*" deny
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- // Other agents still denied by the earlier "*" deny
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
- // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
- // So the task tool is NOT disabled (even though most subagents are denied)
- const disabled = PermissionNext.disabled(["task"], ruleset)
- expect(disabled.has("task")).toBe(false)
- },
- })
- })
- })
|