permission-task.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import { afterEach, describe, test, expect } from "bun:test"
  2. import { Permission } from "../src/permission"
  3. import { Config } from "@/config/config"
  4. import { Instance } from "../src/project/instance"
  5. import { WithInstance } from "../src/project/with-instance"
  6. import { disposeAllInstances, tmpdir } from "./fixture/fixture"
  7. import { AppRuntime } from "../src/effect/app-runtime"
  8. const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
  9. afterEach(async () => {
  10. await disposeAllInstances()
  11. })
  12. describe("Permission.evaluate for permission.task", () => {
  13. const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
  14. Object.entries(rules).map(([pattern, action]) => ({
  15. permission: "task",
  16. pattern,
  17. action,
  18. }))
  19. test("returns ask when no match (default)", () => {
  20. expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
  21. })
  22. test("returns deny for explicit deny", () => {
  23. const ruleset = createRuleset({ "code-reviewer": "deny" })
  24. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  25. })
  26. test("returns allow for explicit allow", () => {
  27. const ruleset = createRuleset({ "code-reviewer": "allow" })
  28. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
  29. })
  30. test("returns ask for explicit ask", () => {
  31. const ruleset = createRuleset({ "code-reviewer": "ask" })
  32. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
  33. })
  34. test("matches wildcard patterns with deny", () => {
  35. const ruleset = createRuleset({ "orchestrator-*": "deny" })
  36. expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
  37. expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
  38. expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
  39. })
  40. test("matches wildcard patterns with allow", () => {
  41. const ruleset = createRuleset({ "orchestrator-*": "allow" })
  42. expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
  43. expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
  44. })
  45. test("matches wildcard patterns with ask", () => {
  46. const ruleset = createRuleset({ "orchestrator-*": "ask" })
  47. expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
  48. const globalRuleset = createRuleset({ "*": "ask" })
  49. expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
  50. })
  51. test("later rules take precedence (last match wins)", () => {
  52. const ruleset = createRuleset({
  53. "orchestrator-*": "deny",
  54. "orchestrator-fast": "allow",
  55. })
  56. expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
  57. expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
  58. })
  59. test("matches global wildcard", () => {
  60. expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
  61. expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
  62. expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
  63. })
  64. })
  65. describe("Permission.disabled for task tool", () => {
  66. // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
  67. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
  68. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
  69. const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
  70. Object.entries(rules).map(([pattern, action]) => ({
  71. permission: "task",
  72. pattern,
  73. action,
  74. }))
  75. test("task tool is disabled when global deny pattern exists (even with specific allows)", () => {
  76. // When "*": "deny" exists, the task tool is disabled because the disabled() function
  77. // only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed
  78. const ruleset = createRuleset({
  79. "orchestrator-*": "allow",
  80. "*": "deny",
  81. })
  82. const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
  83. // The task tool IS disabled because there's a pattern: "*" with action: "deny"
  84. expect(disabled.has("task")).toBe(true)
  85. })
  86. test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => {
  87. const ruleset = createRuleset({
  88. "orchestrator-*": "ask",
  89. "*": "deny",
  90. })
  91. const disabled = Permission.disabled(["task"], ruleset)
  92. // The task tool IS disabled because there's a pattern: "*" with action: "deny"
  93. expect(disabled.has("task")).toBe(true)
  94. })
  95. test("task tool is disabled when global deny pattern exists", () => {
  96. const ruleset = createRuleset({ "*": "deny" })
  97. const disabled = Permission.disabled(["task"], ruleset)
  98. expect(disabled.has("task")).toBe(true)
  99. })
  100. test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => {
  101. // The disabled() function only disables tools when pattern: "*" && action: "deny"
  102. // Specific subagent denies don't disable the task tool - those are handled at runtime
  103. const ruleset = createRuleset({
  104. "orchestrator-*": "deny",
  105. general: "deny",
  106. })
  107. const disabled = Permission.disabled(["task"], ruleset)
  108. // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
  109. expect(disabled.has("task")).toBe(false)
  110. })
  111. test("task tool is enabled when no task rules exist (default ask)", () => {
  112. const disabled = Permission.disabled(["task"], [])
  113. expect(disabled.has("task")).toBe(false)
  114. })
  115. test("task tool is NOT disabled when last wildcard pattern is allow", () => {
  116. // Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled
  117. const ruleset = createRuleset({
  118. "*": "deny",
  119. "orchestrator-coder": "allow",
  120. })
  121. const disabled = Permission.disabled(["task"], ruleset)
  122. // The disabled() function uses findLast and checks if the last matching rule
  123. // has pattern: "*" and action: "deny". In this case, the last rule matching
  124. // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
  125. expect(disabled.has("task")).toBe(false)
  126. })
  127. })
  128. // Integration tests that load permissions from real config files
  129. describe("permission.task with real config files", () => {
  130. test("loads task permissions from opencode.json config", async () => {
  131. await using tmp = await tmpdir({
  132. git: true,
  133. config: {
  134. permission: {
  135. task: {
  136. "*": "allow",
  137. "code-reviewer": "deny",
  138. },
  139. },
  140. },
  141. })
  142. await WithInstance.provide({
  143. directory: tmp.path,
  144. fn: async () => {
  145. const config = await load()
  146. const ruleset = Permission.fromConfig(config.permission ?? {})
  147. // general and orchestrator-fast should be allowed, code-reviewer denied
  148. expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
  149. expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
  150. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  151. },
  152. })
  153. })
  154. test("loads task permissions with wildcard patterns from config", async () => {
  155. await using tmp = await tmpdir({
  156. git: true,
  157. config: {
  158. permission: {
  159. task: {
  160. "*": "ask",
  161. "orchestrator-*": "deny",
  162. },
  163. },
  164. },
  165. })
  166. await WithInstance.provide({
  167. directory: tmp.path,
  168. fn: async () => {
  169. const config = await load()
  170. const ruleset = Permission.fromConfig(config.permission ?? {})
  171. // general and code-reviewer should be ask, orchestrator-* denied
  172. expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
  173. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
  174. expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
  175. },
  176. })
  177. })
  178. test("evaluate respects task permission from config", async () => {
  179. await using tmp = await tmpdir({
  180. git: true,
  181. config: {
  182. permission: {
  183. task: {
  184. general: "allow",
  185. "code-reviewer": "deny",
  186. },
  187. },
  188. },
  189. })
  190. await WithInstance.provide({
  191. directory: tmp.path,
  192. fn: async () => {
  193. const config = await load()
  194. const ruleset = Permission.fromConfig(config.permission ?? {})
  195. expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
  196. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  197. // Unspecified agents default to "ask"
  198. expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
  199. },
  200. })
  201. })
  202. test("mixed permission config with task and other tools", async () => {
  203. await using tmp = await tmpdir({
  204. git: true,
  205. config: {
  206. permission: {
  207. bash: "allow",
  208. edit: "ask",
  209. task: {
  210. "*": "deny",
  211. general: "allow",
  212. },
  213. },
  214. },
  215. })
  216. await WithInstance.provide({
  217. directory: tmp.path,
  218. fn: async () => {
  219. const config = await load()
  220. const ruleset = Permission.fromConfig(config.permission ?? {})
  221. // Verify task permissions
  222. expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
  223. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  224. // Verify other tool permissions
  225. expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
  226. expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
  227. // Verify disabled tools
  228. const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
  229. expect(disabled.has("bash")).toBe(false)
  230. expect(disabled.has("edit")).toBe(false)
  231. // task is NOT disabled because disabled() uses findLast, and the last rule
  232. // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
  233. expect(disabled.has("task")).toBe(false)
  234. },
  235. })
  236. })
  237. test("task tool disabled when global deny comes last in config", async () => {
  238. await using tmp = await tmpdir({
  239. git: true,
  240. config: {
  241. permission: {
  242. task: {
  243. general: "allow",
  244. "code-reviewer": "allow",
  245. "*": "deny",
  246. },
  247. },
  248. },
  249. })
  250. await WithInstance.provide({
  251. directory: tmp.path,
  252. fn: async () => {
  253. const config = await load()
  254. const ruleset = Permission.fromConfig(config.permission ?? {})
  255. // Last matching rule wins - "*" deny is last, so all agents are denied
  256. expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
  257. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  258. expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
  259. // Since "*": "deny" is the last rule, disabled() finds it with findLast
  260. // and sees pattern: "*" with action: "deny", so task is disabled
  261. const disabled = Permission.disabled(["task"], ruleset)
  262. expect(disabled.has("task")).toBe(true)
  263. },
  264. })
  265. })
  266. test("task tool NOT disabled when specific allow comes last in config", async () => {
  267. await using tmp = await tmpdir({
  268. git: true,
  269. config: {
  270. permission: {
  271. task: {
  272. "*": "deny",
  273. general: "allow",
  274. },
  275. },
  276. },
  277. })
  278. await WithInstance.provide({
  279. directory: tmp.path,
  280. fn: async () => {
  281. const config = await load()
  282. const ruleset = Permission.fromConfig(config.permission ?? {})
  283. // Evaluate uses findLast - "general" allow comes after "*" deny
  284. expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
  285. // Other agents still denied by the earlier "*" deny
  286. expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  287. // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
  288. // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
  289. // So the task tool is NOT disabled (even though most subagents are denied)
  290. const disabled = Permission.disabled(["task"], ruleset)
  291. expect(disabled.has("task")).toBe(false)
  292. },
  293. })
  294. })
  295. })