permission-task.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { describe, test, expect } from "bun:test"
  2. import type { Agent } from "../src/agent/agent"
  3. import { filterSubagents } from "../src/tool/task"
  4. import { PermissionNext } from "../src/permission/next"
  5. import { Config } from "../src/config/config"
  6. import { Instance } from "../src/project/instance"
  7. import { tmpdir } from "./fixture/fixture"
  8. describe("filterSubagents - permission.task filtering", () => {
  9. const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
  10. Object.entries(rules).map(([pattern, action]) => ({
  11. permission: "task",
  12. pattern,
  13. action,
  14. }))
  15. const mockAgents = [
  16. { name: "general", mode: "subagent", permission: [], options: {} },
  17. { name: "code-reviewer", mode: "subagent", permission: [], options: {} },
  18. { name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
  19. { name: "orchestrator-slow", mode: "subagent", permission: [], options: {} },
  20. ] as Agent.Info[]
  21. test("returns all agents when permissions config is empty", () => {
  22. const result = filterSubagents(mockAgents, [])
  23. expect(result).toHaveLength(4)
  24. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
  25. })
  26. test("excludes agents with explicit deny", () => {
  27. const ruleset = createRuleset({ "code-reviewer": "deny" })
  28. const result = filterSubagents(mockAgents, ruleset)
  29. expect(result).toHaveLength(3)
  30. expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
  31. })
  32. test("includes agents with explicit allow", () => {
  33. const ruleset = createRuleset({
  34. "code-reviewer": "allow",
  35. general: "deny",
  36. })
  37. const result = filterSubagents(mockAgents, ruleset)
  38. expect(result).toHaveLength(3)
  39. expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
  40. })
  41. test("includes agents with ask permission (user approval is runtime behavior)", () => {
  42. const ruleset = createRuleset({
  43. "code-reviewer": "ask",
  44. general: "deny",
  45. })
  46. const result = filterSubagents(mockAgents, ruleset)
  47. expect(result).toHaveLength(3)
  48. expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
  49. })
  50. test("includes agents with undefined permission (default allow)", () => {
  51. const ruleset = createRuleset({
  52. general: "deny",
  53. })
  54. const result = filterSubagents(mockAgents, ruleset)
  55. expect(result).toHaveLength(3)
  56. expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
  57. })
  58. test("supports wildcard patterns with deny", () => {
  59. const ruleset = createRuleset({ "orchestrator-*": "deny" })
  60. const result = filterSubagents(mockAgents, ruleset)
  61. expect(result).toHaveLength(2)
  62. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
  63. })
  64. test("supports wildcard patterns with allow", () => {
  65. const ruleset = createRuleset({
  66. "*": "allow",
  67. "orchestrator-fast": "deny",
  68. })
  69. const result = filterSubagents(mockAgents, ruleset)
  70. expect(result).toHaveLength(3)
  71. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
  72. })
  73. test("supports wildcard patterns with ask", () => {
  74. const ruleset = createRuleset({
  75. "orchestrator-*": "ask",
  76. })
  77. const result = filterSubagents(mockAgents, ruleset)
  78. expect(result).toHaveLength(4)
  79. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
  80. })
  81. test("longer pattern takes precedence over shorter pattern", () => {
  82. const ruleset = createRuleset({
  83. "orchestrator-*": "deny",
  84. "orchestrator-fast": "allow",
  85. })
  86. const result = filterSubagents(mockAgents, ruleset)
  87. expect(result).toHaveLength(3)
  88. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
  89. })
  90. test("edge case: all agents denied", () => {
  91. const ruleset = createRuleset({ "*": "deny" })
  92. const result = filterSubagents(mockAgents, ruleset)
  93. expect(result).toHaveLength(0)
  94. expect(result).toEqual([])
  95. })
  96. test("edge case: mixed patterns with multiple wildcards", () => {
  97. const ruleset = createRuleset({
  98. "*": "ask",
  99. "orchestrator-*": "deny",
  100. "orchestrator-fast": "allow",
  101. })
  102. const result = filterSubagents(mockAgents, ruleset)
  103. expect(result).toHaveLength(3)
  104. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
  105. })
  106. test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => {
  107. const agents = [
  108. { name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
  109. { name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} },
  110. { name: "orchestrator", mode: "subagent", permission: [], options: {} },
  111. ] as Agent.Info[]
  112. const result = filterSubagents(agents, [])
  113. expect(result).toHaveLength(3)
  114. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"])
  115. })
  116. test("hidden: true agents can be filtered by permission.task deny", () => {
  117. const agents = [
  118. { name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
  119. { name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} },
  120. ] as Agent.Info[]
  121. const ruleset = createRuleset({ general: "deny" })
  122. const result = filterSubagents(agents, ruleset)
  123. expect(result).toHaveLength(1)
  124. expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"])
  125. })
  126. })
  127. describe("PermissionNext.evaluate for permission.task", () => {
  128. const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
  129. Object.entries(rules).map(([pattern, action]) => ({
  130. permission: "task",
  131. pattern,
  132. action,
  133. }))
  134. test("returns ask when no match (default)", () => {
  135. expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
  136. })
  137. test("returns deny for explicit deny", () => {
  138. const ruleset = createRuleset({ "code-reviewer": "deny" })
  139. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  140. })
  141. test("returns allow for explicit allow", () => {
  142. const ruleset = createRuleset({ "code-reviewer": "allow" })
  143. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
  144. })
  145. test("returns ask for explicit ask", () => {
  146. const ruleset = createRuleset({ "code-reviewer": "ask" })
  147. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
  148. })
  149. test("matches wildcard patterns with deny", () => {
  150. const ruleset = createRuleset({ "orchestrator-*": "deny" })
  151. expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
  152. expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
  153. expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
  154. })
  155. test("matches wildcard patterns with allow", () => {
  156. const ruleset = createRuleset({ "orchestrator-*": "allow" })
  157. expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
  158. expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
  159. })
  160. test("matches wildcard patterns with ask", () => {
  161. const ruleset = createRuleset({ "orchestrator-*": "ask" })
  162. expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
  163. const globalRuleset = createRuleset({ "*": "ask" })
  164. expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
  165. })
  166. test("later rules take precedence (last match wins)", () => {
  167. const ruleset = createRuleset({
  168. "orchestrator-*": "deny",
  169. "orchestrator-fast": "allow",
  170. })
  171. expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
  172. expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
  173. })
  174. test("matches global wildcard", () => {
  175. expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
  176. expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
  177. expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
  178. })
  179. })
  180. describe("PermissionNext.disabled for task tool", () => {
  181. // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
  182. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
  183. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
  184. const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
  185. Object.entries(rules).map(([pattern, action]) => ({
  186. permission: "task",
  187. pattern,
  188. action,
  189. }))
  190. test("task tool is disabled when global deny pattern exists (even with specific allows)", () => {
  191. // When "*": "deny" exists, the task tool is disabled because the disabled() function
  192. // only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed
  193. const ruleset = createRuleset({
  194. "orchestrator-*": "allow",
  195. "*": "deny",
  196. })
  197. const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
  198. // The task tool IS disabled because there's a pattern: "*" with action: "deny"
  199. expect(disabled.has("task")).toBe(true)
  200. })
  201. test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => {
  202. const ruleset = createRuleset({
  203. "orchestrator-*": "ask",
  204. "*": "deny",
  205. })
  206. const disabled = PermissionNext.disabled(["task"], ruleset)
  207. // The task tool IS disabled because there's a pattern: "*" with action: "deny"
  208. expect(disabled.has("task")).toBe(true)
  209. })
  210. test("task tool is disabled when global deny pattern exists", () => {
  211. const ruleset = createRuleset({ "*": "deny" })
  212. const disabled = PermissionNext.disabled(["task"], ruleset)
  213. expect(disabled.has("task")).toBe(true)
  214. })
  215. test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => {
  216. // The disabled() function only disables tools when pattern: "*" && action: "deny"
  217. // Specific subagent denies don't disable the task tool - those are handled at runtime
  218. const ruleset = createRuleset({
  219. "orchestrator-*": "deny",
  220. general: "deny",
  221. })
  222. const disabled = PermissionNext.disabled(["task"], ruleset)
  223. // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
  224. expect(disabled.has("task")).toBe(false)
  225. })
  226. test("task tool is enabled when no task rules exist (default ask)", () => {
  227. const disabled = PermissionNext.disabled(["task"], [])
  228. expect(disabled.has("task")).toBe(false)
  229. })
  230. test("task tool is NOT disabled when last wildcard pattern is allow", () => {
  231. // Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled
  232. const ruleset = createRuleset({
  233. "*": "deny",
  234. "orchestrator-coder": "allow",
  235. })
  236. const disabled = PermissionNext.disabled(["task"], ruleset)
  237. // The disabled() function uses findLast and checks if the last matching rule
  238. // has pattern: "*" and action: "deny". In this case, the last rule matching
  239. // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
  240. expect(disabled.has("task")).toBe(false)
  241. })
  242. })
  243. // Integration tests that load permissions from real config files
  244. describe("permission.task with real config files", () => {
  245. const mockAgents = [
  246. { name: "general", mode: "subagent", permission: [], options: {} },
  247. { name: "code-reviewer", mode: "subagent", permission: [], options: {} },
  248. { name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
  249. ] as Agent.Info[]
  250. test("loads task permissions from opencode.json config", async () => {
  251. await using tmp = await tmpdir({
  252. git: true,
  253. config: {
  254. permission: {
  255. task: {
  256. "*": "allow",
  257. "code-reviewer": "deny",
  258. },
  259. },
  260. },
  261. })
  262. await Instance.provide({
  263. directory: tmp.path,
  264. fn: async () => {
  265. const config = await Config.get()
  266. const ruleset = PermissionNext.fromConfig(config.permission ?? {})
  267. const result = filterSubagents(mockAgents, ruleset)
  268. expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"])
  269. },
  270. })
  271. })
  272. test("loads task permissions with wildcard patterns from config", async () => {
  273. await using tmp = await tmpdir({
  274. git: true,
  275. config: {
  276. permission: {
  277. task: {
  278. "*": "ask",
  279. "orchestrator-*": "deny",
  280. },
  281. },
  282. },
  283. })
  284. await Instance.provide({
  285. directory: tmp.path,
  286. fn: async () => {
  287. const config = await Config.get()
  288. const ruleset = PermissionNext.fromConfig(config.permission ?? {})
  289. const result = filterSubagents(mockAgents, ruleset)
  290. expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
  291. },
  292. })
  293. })
  294. test("evaluate respects task permission from config", async () => {
  295. await using tmp = await tmpdir({
  296. git: true,
  297. config: {
  298. permission: {
  299. task: {
  300. general: "allow",
  301. "code-reviewer": "deny",
  302. },
  303. },
  304. },
  305. })
  306. await Instance.provide({
  307. directory: tmp.path,
  308. fn: async () => {
  309. const config = await Config.get()
  310. const ruleset = PermissionNext.fromConfig(config.permission ?? {})
  311. expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
  312. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  313. // Unspecified agents default to "ask"
  314. expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
  315. },
  316. })
  317. })
  318. test("mixed permission config with task and other tools", async () => {
  319. await using tmp = await tmpdir({
  320. git: true,
  321. config: {
  322. permission: {
  323. bash: "allow",
  324. edit: "ask",
  325. task: {
  326. "*": "deny",
  327. general: "allow",
  328. },
  329. },
  330. },
  331. })
  332. await Instance.provide({
  333. directory: tmp.path,
  334. fn: async () => {
  335. const config = await Config.get()
  336. const ruleset = PermissionNext.fromConfig(config.permission ?? {})
  337. // Verify task permissions
  338. expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
  339. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  340. // Verify other tool permissions
  341. expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
  342. expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
  343. // Verify disabled tools
  344. const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
  345. expect(disabled.has("bash")).toBe(false)
  346. expect(disabled.has("edit")).toBe(false)
  347. // task is NOT disabled because disabled() uses findLast, and the last rule
  348. // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
  349. expect(disabled.has("task")).toBe(false)
  350. },
  351. })
  352. })
  353. test("task tool disabled when global deny comes last in config", async () => {
  354. await using tmp = await tmpdir({
  355. git: true,
  356. config: {
  357. permission: {
  358. task: {
  359. general: "allow",
  360. "code-reviewer": "allow",
  361. "*": "deny",
  362. },
  363. },
  364. },
  365. })
  366. await Instance.provide({
  367. directory: tmp.path,
  368. fn: async () => {
  369. const config = await Config.get()
  370. const ruleset = PermissionNext.fromConfig(config.permission ?? {})
  371. // Last matching rule wins - "*" deny is last, so all agents are denied
  372. expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
  373. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  374. expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
  375. // Since "*": "deny" is the last rule, disabled() finds it with findLast
  376. // and sees pattern: "*" with action: "deny", so task is disabled
  377. const disabled = PermissionNext.disabled(["task"], ruleset)
  378. expect(disabled.has("task")).toBe(true)
  379. },
  380. })
  381. })
  382. test("task tool NOT disabled when specific allow comes last in config", async () => {
  383. await using tmp = await tmpdir({
  384. git: true,
  385. config: {
  386. permission: {
  387. task: {
  388. "*": "deny",
  389. general: "allow",
  390. },
  391. },
  392. },
  393. })
  394. await Instance.provide({
  395. directory: tmp.path,
  396. fn: async () => {
  397. const config = await Config.get()
  398. const ruleset = PermissionNext.fromConfig(config.permission ?? {})
  399. // Evaluate uses findLast - "general" allow comes after "*" deny
  400. expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
  401. // Other agents still denied by the earlier "*" deny
  402. expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
  403. // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
  404. // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
  405. // So the task tool is NOT disabled (even though most subagents are denied)
  406. const disabled = PermissionNext.disabled(["task"], ruleset)
  407. expect(disabled.has("task")).toBe(false)
  408. },
  409. })
  410. })
  411. })