permission-task.test.ts 12 KB

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