permission-task.test.ts 13 KB

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