permission-task.test.ts 13 KB

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