permission-task.test.ts 12 KB

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