next.test.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174
  1. import { PermissionV1 } from "@kirincode-ai/core/v1/permission"
  2. import { test, expect } from "bun:test"
  3. import os from "os"
  4. import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
  5. import { EventV2Bridge } from "../../src/event-v2-bridge"
  6. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  7. import { Permission } from "../../src/permission"
  8. import { InstanceBootstrap } from "../../src/project/bootstrap"
  9. import { InstanceStore } from "../../src/project/instance-store"
  10. import { TestInstance, tmpdirScoped } from "../fixture/fixture"
  11. import { testEffect } from "../lib/effect"
  12. import { MessageID, SessionID } from "../../src/session/schema"
  13. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  14. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  15. const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
  16. const env = AppNodeBuilder.build(
  17. LayerNode.group([Permission.node, EventV2Bridge.node, CrossSpawnSpawner.node, InstanceStore.node]),
  18. [[InstanceStore.bootstrapNode, noopBootstrap]],
  19. )
  20. const it = testEffect(env)
  21. const rejectAll = (message?: string) =>
  22. Effect.gen(function* () {
  23. const permission = yield* Permission.Service
  24. for (const req of yield* permission.list()) {
  25. yield* permission.reply({
  26. requestID: req.id,
  27. reply: "reject",
  28. message,
  29. })
  30. }
  31. })
  32. const waitForPending = (count: number) =>
  33. Effect.gen(function* () {
  34. const permission = yield* Permission.Service
  35. return yield* Effect.gen(function* () {
  36. while (true) {
  37. const list = yield* permission.list()
  38. if (list.length === count) return list
  39. yield* Effect.sleep("10 millis")
  40. }
  41. }).pipe(
  42. Effect.timeoutOrElse({
  43. duration: "1 second",
  44. orElse: () => Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`)),
  45. }),
  46. )
  47. })
  48. const fail = <A, E, R>(self: Effect.Effect<A, E, R>) =>
  49. Effect.gen(function* () {
  50. const exit = yield* self.pipe(Effect.exit)
  51. if (Exit.isFailure(exit)) return Cause.squash(exit.cause)
  52. throw new Error("expected permission effect to fail")
  53. })
  54. const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
  55. Effect.gen(function* () {
  56. const permission = yield* Permission.Service
  57. return yield* permission.ask(input)
  58. })
  59. const reply = (input: Parameters<Permission.Interface["reply"]>[0]) =>
  60. Effect.gen(function* () {
  61. const permission = yield* Permission.Service
  62. return yield* permission.reply(input)
  63. })
  64. const list = () =>
  65. Effect.gen(function* () {
  66. const permission = yield* Permission.Service
  67. return yield* permission.list()
  68. })
  69. // fromConfig tests
  70. test("fromConfig - string value becomes wildcard rule", () => {
  71. const result = Permission.fromConfig({ bash: "allow" })
  72. expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
  73. })
  74. test("fromConfig - object value converts to rules array", () => {
  75. const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
  76. expect(result).toEqual([
  77. { permission: "bash", pattern: "*", action: "allow" },
  78. { permission: "bash", pattern: "rm", action: "deny" },
  79. ])
  80. })
  81. test("fromConfig - mixed string and object values", () => {
  82. const result = Permission.fromConfig({
  83. bash: { "*": "allow", rm: "deny" },
  84. edit: "allow",
  85. webfetch: "ask",
  86. })
  87. expect(result).toEqual([
  88. { permission: "bash", pattern: "*", action: "allow" },
  89. { permission: "bash", pattern: "rm", action: "deny" },
  90. { permission: "edit", pattern: "*", action: "allow" },
  91. { permission: "webfetch", pattern: "*", action: "ask" },
  92. ])
  93. })
  94. test("fromConfig - empty object", () => {
  95. const result = Permission.fromConfig({})
  96. expect(result).toEqual([])
  97. })
  98. test("fromConfig - expands tilde to home directory", () => {
  99. const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
  100. expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
  101. })
  102. test("fromConfig - expands $HOME to home directory", () => {
  103. const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
  104. expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
  105. })
  106. test("fromConfig - expands $HOME without trailing slash", () => {
  107. const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
  108. expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
  109. })
  110. test("fromConfig - does not expand tilde in middle of path", () => {
  111. const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
  112. expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
  113. })
  114. // Permission precedence follows config insertion order. `evaluate()` uses the
  115. // last matching rule, so later config entries intentionally override earlier
  116. // entries even when a wildcard appears after a specific permission.
  117. test("fromConfig - preserves top-level config key order", () => {
  118. const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
  119. const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
  120. expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "bash"])
  121. expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"])
  122. expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
  123. expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny")
  124. })
  125. test("fromConfig - wildcard acts as fallback when it appears before specifics", () => {
  126. const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow" })
  127. expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
  128. expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
  129. })
  130. test("fromConfig - top-level ordering is not sorted by wildcard specificity", () => {
  131. const ruleset = Permission.fromConfig({
  132. bash: "allow",
  133. "*": "ask",
  134. edit: "deny",
  135. "mcp_*": "allow",
  136. })
  137. expect(ruleset.map((r) => r.permission)).toEqual(["bash", "*", "edit", "mcp_*"])
  138. })
  139. test("fromConfig - sub-pattern insertion order inside a tool key is preserved", () => {
  140. const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
  141. expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
  142. expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
  143. expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
  144. })
  145. test("fromConfig - documented fallback-first example", () => {
  146. const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
  147. expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
  148. expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
  149. expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask")
  150. })
  151. test("fromConfig - expands exact tilde to home directory", () => {
  152. const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
  153. expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
  154. })
  155. test("evaluate - matches expanded tilde pattern", () => {
  156. const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
  157. const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
  158. expect(result.action).toBe("allow")
  159. })
  160. test("evaluate - matches expanded $HOME pattern", () => {
  161. const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
  162. const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
  163. expect(result.action).toBe("allow")
  164. })
  165. // merge tests
  166. test("merge - simple concatenation", () => {
  167. const result = Permission.merge(
  168. [{ permission: "bash", pattern: "*", action: "allow" }],
  169. [{ permission: "bash", pattern: "*", action: "deny" }],
  170. )
  171. expect(result).toEqual([
  172. { permission: "bash", pattern: "*", action: "allow" },
  173. { permission: "bash", pattern: "*", action: "deny" },
  174. ])
  175. })
  176. test("merge - adds new permission", () => {
  177. const result = Permission.merge(
  178. [{ permission: "bash", pattern: "*", action: "allow" }],
  179. [{ permission: "edit", pattern: "*", action: "deny" }],
  180. )
  181. expect(result).toEqual([
  182. { permission: "bash", pattern: "*", action: "allow" },
  183. { permission: "edit", pattern: "*", action: "deny" },
  184. ])
  185. })
  186. test("merge - concatenates rules for same permission", () => {
  187. const result = Permission.merge(
  188. [{ permission: "bash", pattern: "foo", action: "ask" }],
  189. [{ permission: "bash", pattern: "*", action: "deny" }],
  190. )
  191. expect(result).toEqual([
  192. { permission: "bash", pattern: "foo", action: "ask" },
  193. { permission: "bash", pattern: "*", action: "deny" },
  194. ])
  195. })
  196. test("merge - multiple rulesets", () => {
  197. const result = Permission.merge(
  198. [{ permission: "bash", pattern: "*", action: "allow" }],
  199. [{ permission: "bash", pattern: "rm", action: "ask" }],
  200. [{ permission: "edit", pattern: "*", action: "allow" }],
  201. )
  202. expect(result).toEqual([
  203. { permission: "bash", pattern: "*", action: "allow" },
  204. { permission: "bash", pattern: "rm", action: "ask" },
  205. { permission: "edit", pattern: "*", action: "allow" },
  206. ])
  207. })
  208. test("merge - empty ruleset does nothing", () => {
  209. const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
  210. expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
  211. })
  212. test("merge - preserves rule order", () => {
  213. const result = Permission.merge(
  214. [
  215. { permission: "edit", pattern: "src/*", action: "allow" },
  216. { permission: "edit", pattern: "src/secret/*", action: "deny" },
  217. ],
  218. [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
  219. )
  220. expect(result).toEqual([
  221. { permission: "edit", pattern: "src/*", action: "allow" },
  222. { permission: "edit", pattern: "src/secret/*", action: "deny" },
  223. { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
  224. ])
  225. })
  226. test("merge - config permission overrides default ask", () => {
  227. const defaults: PermissionV1.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
  228. const config: PermissionV1.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
  229. const merged = Permission.merge(defaults, config)
  230. expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
  231. expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
  232. })
  233. test("merge - config ask overrides default allow", () => {
  234. const defaults: PermissionV1.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
  235. const config: PermissionV1.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
  236. const merged = Permission.merge(defaults, config)
  237. expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
  238. })
  239. // evaluate tests
  240. test("evaluate - exact pattern match", () => {
  241. const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
  242. expect(result.action).toBe("deny")
  243. })
  244. test("evaluate - wildcard pattern match", () => {
  245. const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
  246. expect(result.action).toBe("allow")
  247. })
  248. test("evaluate - last matching rule wins", () => {
  249. const result = Permission.evaluate("bash", "rm", [
  250. { permission: "bash", pattern: "*", action: "allow" },
  251. { permission: "bash", pattern: "rm", action: "deny" },
  252. ])
  253. expect(result.action).toBe("deny")
  254. })
  255. test("evaluate - last matching rule wins (wildcard after specific)", () => {
  256. const result = Permission.evaluate("bash", "rm", [
  257. { permission: "bash", pattern: "rm", action: "deny" },
  258. { permission: "bash", pattern: "*", action: "allow" },
  259. ])
  260. expect(result.action).toBe("allow")
  261. })
  262. test("evaluate - glob pattern match", () => {
  263. const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
  264. expect(result.action).toBe("allow")
  265. })
  266. test("evaluate - last matching glob wins", () => {
  267. const result = Permission.evaluate("edit", "src/components/Button.tsx", [
  268. { permission: "edit", pattern: "src/*", action: "deny" },
  269. { permission: "edit", pattern: "src/components/*", action: "allow" },
  270. ])
  271. expect(result.action).toBe("allow")
  272. })
  273. test("evaluate - order matters for specificity", () => {
  274. const result = Permission.evaluate("edit", "src/components/Button.tsx", [
  275. { permission: "edit", pattern: "src/components/*", action: "allow" },
  276. { permission: "edit", pattern: "src/*", action: "deny" },
  277. ])
  278. expect(result.action).toBe("deny")
  279. })
  280. test("evaluate - unknown permission returns ask", () => {
  281. const result = Permission.evaluate("unknown_tool", "anything", [
  282. { permission: "bash", pattern: "*", action: "allow" },
  283. ])
  284. expect(result.action).toBe("ask")
  285. })
  286. test("evaluate - empty ruleset returns ask", () => {
  287. const result = Permission.evaluate("bash", "rm", [])
  288. expect(result.action).toBe("ask")
  289. })
  290. test("evaluate - no matching pattern returns ask", () => {
  291. const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
  292. expect(result.action).toBe("ask")
  293. })
  294. test("evaluate - empty rules array returns ask", () => {
  295. const result = Permission.evaluate("bash", "rm", [])
  296. expect(result.action).toBe("ask")
  297. })
  298. test("evaluate - multiple matching patterns, last wins", () => {
  299. const result = Permission.evaluate("edit", "src/secret.ts", [
  300. { permission: "edit", pattern: "*", action: "ask" },
  301. { permission: "edit", pattern: "src/*", action: "allow" },
  302. { permission: "edit", pattern: "src/secret.ts", action: "deny" },
  303. ])
  304. expect(result.action).toBe("deny")
  305. })
  306. test("evaluate - non-matching patterns are skipped", () => {
  307. const result = Permission.evaluate("edit", "src/foo.ts", [
  308. { permission: "edit", pattern: "*", action: "ask" },
  309. { permission: "edit", pattern: "test/*", action: "deny" },
  310. { permission: "edit", pattern: "src/*", action: "allow" },
  311. ])
  312. expect(result.action).toBe("allow")
  313. })
  314. test("evaluate - exact match at end wins over earlier wildcard", () => {
  315. const result = Permission.evaluate("bash", "/bin/rm", [
  316. { permission: "bash", pattern: "*", action: "allow" },
  317. { permission: "bash", pattern: "/bin/rm", action: "deny" },
  318. ])
  319. expect(result.action).toBe("deny")
  320. })
  321. test("evaluate - wildcard at end overrides earlier exact match", () => {
  322. const result = Permission.evaluate("bash", "/bin/rm", [
  323. { permission: "bash", pattern: "/bin/rm", action: "deny" },
  324. { permission: "bash", pattern: "*", action: "allow" },
  325. ])
  326. expect(result.action).toBe("allow")
  327. })
  328. // wildcard permission tests
  329. test("evaluate - wildcard permission matches any permission", () => {
  330. const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
  331. expect(result.action).toBe("deny")
  332. })
  333. test("evaluate - wildcard permission with specific pattern", () => {
  334. const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
  335. expect(result.action).toBe("deny")
  336. })
  337. test("evaluate - glob permission pattern", () => {
  338. const result = Permission.evaluate("mcp_server_tool", "anything", [
  339. { permission: "mcp_*", pattern: "*", action: "allow" },
  340. ])
  341. expect(result.action).toBe("allow")
  342. })
  343. test("evaluate - specific permission and wildcard permission combined", () => {
  344. const result = Permission.evaluate("bash", "rm", [
  345. { permission: "*", pattern: "*", action: "deny" },
  346. { permission: "bash", pattern: "*", action: "allow" },
  347. ])
  348. expect(result.action).toBe("allow")
  349. })
  350. test("evaluate - wildcard permission does not match when specific exists", () => {
  351. const result = Permission.evaluate("edit", "src/foo.ts", [
  352. { permission: "*", pattern: "*", action: "deny" },
  353. { permission: "edit", pattern: "src/*", action: "allow" },
  354. ])
  355. expect(result.action).toBe("allow")
  356. })
  357. test("evaluate - multiple matching permission patterns combine rules", () => {
  358. const result = Permission.evaluate("mcp_dangerous", "anything", [
  359. { permission: "*", pattern: "*", action: "ask" },
  360. { permission: "mcp_*", pattern: "*", action: "allow" },
  361. { permission: "mcp_dangerous", pattern: "*", action: "deny" },
  362. ])
  363. expect(result.action).toBe("deny")
  364. })
  365. test("evaluate - wildcard permission fallback for unknown tool", () => {
  366. const result = Permission.evaluate("unknown_tool", "anything", [
  367. { permission: "*", pattern: "*", action: "ask" },
  368. { permission: "bash", pattern: "*", action: "allow" },
  369. ])
  370. expect(result.action).toBe("ask")
  371. })
  372. test("evaluate - later wildcard permission can override earlier specific permission", () => {
  373. const result = Permission.evaluate("bash", "rm", [
  374. { permission: "bash", pattern: "*", action: "allow" },
  375. { permission: "*", pattern: "*", action: "deny" },
  376. ])
  377. expect(result.action).toBe("deny")
  378. })
  379. test("evaluate - merges multiple rulesets", () => {
  380. const config: PermissionV1.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
  381. const approved: PermissionV1.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
  382. const result = Permission.evaluate("bash", "rm", config, approved)
  383. expect(result.action).toBe("deny")
  384. })
  385. // disabled tests
  386. test("disabled - returns empty set when all tools allowed", () => {
  387. const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
  388. expect(result.size).toBe(0)
  389. })
  390. test("disabled - disables tool when denied", () => {
  391. const result = Permission.disabled(
  392. ["bash", "edit", "read"],
  393. [
  394. { permission: "*", pattern: "*", action: "allow" },
  395. { permission: "bash", pattern: "*", action: "deny" },
  396. ],
  397. )
  398. expect(result.has("bash")).toBe(true)
  399. expect(result.has("edit")).toBe(false)
  400. expect(result.has("read")).toBe(false)
  401. })
  402. test("disabled - disables edit/write/apply_patch when edit denied", () => {
  403. const result = Permission.disabled(
  404. ["edit", "write", "apply_patch", "bash"],
  405. [
  406. { permission: "*", pattern: "*", action: "allow" },
  407. { permission: "edit", pattern: "*", action: "deny" },
  408. ],
  409. )
  410. expect(result.has("edit")).toBe(true)
  411. expect(result.has("write")).toBe(true)
  412. expect(result.has("apply_patch")).toBe(true)
  413. expect(result.has("bash")).toBe(false)
  414. })
  415. test("disabled - does not disable when partially denied", () => {
  416. const result = Permission.disabled(
  417. ["bash"],
  418. [
  419. { permission: "bash", pattern: "*", action: "allow" },
  420. { permission: "bash", pattern: "rm *", action: "deny" },
  421. ],
  422. )
  423. expect(result.has("bash")).toBe(false)
  424. })
  425. test("disabled - does not disable when action is ask", () => {
  426. const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
  427. expect(result.size).toBe(0)
  428. })
  429. test("disabled - does not disable when specific allow after wildcard deny", () => {
  430. const result = Permission.disabled(
  431. ["bash"],
  432. [
  433. { permission: "bash", pattern: "*", action: "deny" },
  434. { permission: "bash", pattern: "echo *", action: "allow" },
  435. ],
  436. )
  437. expect(result.has("bash")).toBe(false)
  438. })
  439. test("disabled - does not disable when wildcard allow after deny", () => {
  440. const result = Permission.disabled(
  441. ["bash"],
  442. [
  443. { permission: "bash", pattern: "rm *", action: "deny" },
  444. { permission: "bash", pattern: "*", action: "allow" },
  445. ],
  446. )
  447. expect(result.has("bash")).toBe(false)
  448. })
  449. test("disabled - disables multiple tools", () => {
  450. const result = Permission.disabled(
  451. ["bash", "edit", "webfetch"],
  452. [
  453. { permission: "bash", pattern: "*", action: "deny" },
  454. { permission: "edit", pattern: "*", action: "deny" },
  455. { permission: "webfetch", pattern: "*", action: "deny" },
  456. ],
  457. )
  458. expect(result.has("bash")).toBe(true)
  459. expect(result.has("edit")).toBe(true)
  460. expect(result.has("webfetch")).toBe(true)
  461. })
  462. test("disabled - wildcard permission denies all tools", () => {
  463. const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
  464. expect(result.has("bash")).toBe(true)
  465. expect(result.has("edit")).toBe(true)
  466. expect(result.has("read")).toBe(true)
  467. })
  468. test("disabled - specific allow overrides wildcard deny", () => {
  469. const result = Permission.disabled(
  470. ["bash", "edit", "read"],
  471. [
  472. { permission: "*", pattern: "*", action: "deny" },
  473. { permission: "bash", pattern: "*", action: "allow" },
  474. ],
  475. )
  476. expect(result.has("bash")).toBe(false)
  477. expect(result.has("edit")).toBe(true)
  478. expect(result.has("read")).toBe(true)
  479. })
  480. // ask tests
  481. it.instance(
  482. "ask - resolves immediately when action is allow",
  483. () =>
  484. Effect.gen(function* () {
  485. const result = yield* ask({
  486. sessionID: SessionID.make("session_test"),
  487. permission: "bash",
  488. patterns: ["ls"],
  489. metadata: {},
  490. always: [],
  491. ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
  492. })
  493. expect(result).toBeUndefined()
  494. }),
  495. { git: true },
  496. )
  497. it.instance(
  498. "ask - throws DeniedError when action is deny",
  499. () =>
  500. Effect.gen(function* () {
  501. const err = yield* fail(
  502. ask({
  503. sessionID: SessionID.make("session_test"),
  504. permission: "bash",
  505. patterns: ["rm -rf /"],
  506. metadata: {},
  507. always: [],
  508. ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
  509. }),
  510. )
  511. expect(err).toBeInstanceOf(PermissionV1.DeniedError)
  512. }),
  513. { git: true },
  514. )
  515. it.instance(
  516. "ask - stays pending when action is ask",
  517. () =>
  518. Effect.gen(function* () {
  519. const fiber = yield* ask({
  520. sessionID: SessionID.make("session_test"),
  521. permission: "bash",
  522. patterns: ["ls"],
  523. metadata: {},
  524. always: [],
  525. ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
  526. }).pipe(Effect.forkScoped)
  527. expect(yield* waitForPending(1)).toHaveLength(1)
  528. yield* rejectAll()
  529. yield* Fiber.await(fiber)
  530. }),
  531. { git: true },
  532. )
  533. it.instance(
  534. "ask - adds request to pending list",
  535. () =>
  536. Effect.gen(function* () {
  537. const fiber = yield* ask({
  538. sessionID: SessionID.make("session_test"),
  539. permission: "bash",
  540. patterns: ["ls"],
  541. metadata: { cmd: "ls" },
  542. always: ["ls"],
  543. tool: {
  544. messageID: MessageID.make("msg_test"),
  545. callID: "call_test",
  546. },
  547. ruleset: [],
  548. }).pipe(Effect.forkScoped)
  549. const items = yield* waitForPending(1)
  550. expect(items).toHaveLength(1)
  551. expect(items[0]).toMatchObject({
  552. sessionID: SessionID.make("session_test"),
  553. permission: "bash",
  554. patterns: ["ls"],
  555. metadata: { cmd: "ls" },
  556. always: ["ls"],
  557. tool: {
  558. messageID: MessageID.make("msg_test"),
  559. callID: "call_test",
  560. },
  561. })
  562. yield* rejectAll()
  563. yield* Fiber.await(fiber)
  564. }),
  565. { git: true },
  566. )
  567. it.instance(
  568. "ask - publishes asked event",
  569. () =>
  570. Effect.gen(function* () {
  571. const events = yield* EventV2Bridge.Service
  572. const seen = yield* Deferred.make<PermissionV1.Request>()
  573. const unsub = yield* events.listen((event) => {
  574. if (event.type === Permission.Event.Asked.type)
  575. Deferred.doneUnsafe(seen, Effect.succeed(event.data as PermissionV1.Request))
  576. return Effect.void
  577. })
  578. yield* Effect.addFinalizer(() => unsub)
  579. const fiber = yield* ask({
  580. sessionID: SessionID.make("session_test"),
  581. permission: "bash",
  582. patterns: ["ls"],
  583. metadata: { cmd: "ls" },
  584. always: ["ls"],
  585. tool: {
  586. messageID: MessageID.make("msg_test"),
  587. callID: "call_test",
  588. },
  589. ruleset: [],
  590. }).pipe(Effect.forkScoped)
  591. expect(yield* waitForPending(1)).toHaveLength(1)
  592. expect(
  593. yield* Deferred.await(seen).pipe(
  594. Effect.timeoutOrElse({
  595. duration: "1 second",
  596. orElse: () => Effect.fail(new Error("timed out waiting for permission asked event")),
  597. }),
  598. ),
  599. ).toMatchObject({
  600. sessionID: SessionID.make("session_test"),
  601. permission: "bash",
  602. patterns: ["ls"],
  603. })
  604. yield* rejectAll()
  605. yield* Fiber.await(fiber)
  606. }),
  607. { git: true },
  608. )
  609. // reply tests
  610. it.instance(
  611. "reply - once resolves the pending ask",
  612. () =>
  613. Effect.gen(function* () {
  614. const fiber = yield* ask({
  615. id: PermissionV1.ID.make("per_test1"),
  616. sessionID: SessionID.make("session_test"),
  617. permission: "bash",
  618. patterns: ["ls"],
  619. metadata: {},
  620. always: [],
  621. ruleset: [],
  622. }).pipe(Effect.forkScoped)
  623. yield* waitForPending(1)
  624. yield* reply({ requestID: PermissionV1.ID.make("per_test1"), reply: "once" })
  625. yield* Fiber.join(fiber)
  626. }),
  627. { git: true },
  628. )
  629. it.instance(
  630. "reply - reject throws RejectedError",
  631. () =>
  632. Effect.gen(function* () {
  633. const fiber = yield* ask({
  634. id: PermissionV1.ID.make("per_test2"),
  635. sessionID: SessionID.make("session_test"),
  636. permission: "bash",
  637. patterns: ["ls"],
  638. metadata: {},
  639. always: [],
  640. ruleset: [],
  641. }).pipe(Effect.forkScoped)
  642. yield* waitForPending(1)
  643. yield* reply({ requestID: PermissionV1.ID.make("per_test2"), reply: "reject" })
  644. const exit = yield* Fiber.await(fiber)
  645. expect(Exit.isFailure(exit)).toBe(true)
  646. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError)
  647. }),
  648. { git: true },
  649. )
  650. it.instance(
  651. "reply - reject with message throws CorrectedError",
  652. () =>
  653. Effect.gen(function* () {
  654. const fiber = yield* ask({
  655. id: PermissionV1.ID.make("per_test2b"),
  656. sessionID: SessionID.make("session_test"),
  657. permission: "bash",
  658. patterns: ["ls"],
  659. metadata: {},
  660. always: [],
  661. ruleset: [],
  662. }).pipe(Effect.forkScoped)
  663. yield* waitForPending(1)
  664. yield* reply({
  665. requestID: PermissionV1.ID.make("per_test2b"),
  666. reply: "reject",
  667. message: "Use a safer command",
  668. })
  669. const exit = yield* Fiber.await(fiber)
  670. expect(Exit.isFailure(exit)).toBe(true)
  671. if (Exit.isFailure(exit)) {
  672. const err = Cause.squash(exit.cause)
  673. expect(err).toBeInstanceOf(PermissionV1.CorrectedError)
  674. expect(String(err)).toContain("Use a safer command")
  675. }
  676. }),
  677. { git: true },
  678. )
  679. it.instance(
  680. "reply - always persists approval and resolves",
  681. () =>
  682. Effect.gen(function* () {
  683. const fiber = yield* ask({
  684. id: PermissionV1.ID.make("per_test3"),
  685. sessionID: SessionID.make("session_test"),
  686. permission: "bash",
  687. patterns: ["ls"],
  688. metadata: {},
  689. always: ["ls"],
  690. ruleset: [],
  691. }).pipe(Effect.forkScoped)
  692. yield* waitForPending(1)
  693. yield* reply({ requestID: PermissionV1.ID.make("per_test3"), reply: "always" })
  694. yield* Fiber.join(fiber)
  695. const result = yield* ask({
  696. sessionID: SessionID.make("session_test2"),
  697. permission: "bash",
  698. patterns: ["ls"],
  699. metadata: {},
  700. always: [],
  701. ruleset: [],
  702. })
  703. expect(result).toBeUndefined()
  704. }),
  705. { git: true },
  706. )
  707. it.instance(
  708. "reply - reject cancels all pending for same session",
  709. () =>
  710. Effect.gen(function* () {
  711. const a = yield* ask({
  712. id: PermissionV1.ID.make("per_test4a"),
  713. sessionID: SessionID.make("session_same"),
  714. permission: "bash",
  715. patterns: ["ls"],
  716. metadata: {},
  717. always: [],
  718. ruleset: [],
  719. }).pipe(Effect.forkScoped)
  720. const b = yield* ask({
  721. id: PermissionV1.ID.make("per_test4b"),
  722. sessionID: SessionID.make("session_same"),
  723. permission: "edit",
  724. patterns: ["foo.ts"],
  725. metadata: {},
  726. always: [],
  727. ruleset: [],
  728. }).pipe(Effect.forkScoped)
  729. yield* waitForPending(2)
  730. yield* reply({ requestID: PermissionV1.ID.make("per_test4a"), reply: "reject" })
  731. const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
  732. expect(Exit.isFailure(ea)).toBe(true)
  733. expect(Exit.isFailure(eb)).toBe(true)
  734. if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(PermissionV1.RejectedError)
  735. if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(PermissionV1.RejectedError)
  736. }),
  737. { git: true },
  738. )
  739. it.instance(
  740. "reply - always resolves matching pending requests in same session",
  741. () =>
  742. Effect.gen(function* () {
  743. const a = yield* ask({
  744. id: PermissionV1.ID.make("per_test5a"),
  745. sessionID: SessionID.make("session_same"),
  746. permission: "bash",
  747. patterns: ["ls"],
  748. metadata: {},
  749. always: ["ls"],
  750. ruleset: [],
  751. }).pipe(Effect.forkScoped)
  752. const b = yield* ask({
  753. id: PermissionV1.ID.make("per_test5b"),
  754. sessionID: SessionID.make("session_same"),
  755. permission: "bash",
  756. patterns: ["ls"],
  757. metadata: {},
  758. always: [],
  759. ruleset: [],
  760. }).pipe(Effect.forkScoped)
  761. yield* waitForPending(2)
  762. yield* reply({ requestID: PermissionV1.ID.make("per_test5a"), reply: "always" })
  763. yield* Fiber.join(a)
  764. yield* Fiber.join(b)
  765. expect(yield* list()).toHaveLength(0)
  766. }),
  767. { git: true },
  768. )
  769. it.instance(
  770. "reply - always keeps other session pending",
  771. () =>
  772. Effect.gen(function* () {
  773. const a = yield* ask({
  774. id: PermissionV1.ID.make("per_test6a"),
  775. sessionID: SessionID.make("session_a"),
  776. permission: "bash",
  777. patterns: ["ls"],
  778. metadata: {},
  779. always: ["ls"],
  780. ruleset: [],
  781. }).pipe(Effect.forkScoped)
  782. const b = yield* ask({
  783. id: PermissionV1.ID.make("per_test6b"),
  784. sessionID: SessionID.make("session_b"),
  785. permission: "bash",
  786. patterns: ["ls"],
  787. metadata: {},
  788. always: [],
  789. ruleset: [],
  790. }).pipe(Effect.forkScoped)
  791. yield* waitForPending(2)
  792. yield* reply({ requestID: PermissionV1.ID.make("per_test6a"), reply: "always" })
  793. yield* Fiber.join(a)
  794. expect((yield* list()).map((item) => item.id)).toEqual([PermissionV1.ID.make("per_test6b")])
  795. yield* rejectAll()
  796. yield* Fiber.await(b)
  797. }),
  798. { git: true },
  799. )
  800. it.instance(
  801. "reply - publishes replied event",
  802. () =>
  803. Effect.gen(function* () {
  804. const events = yield* EventV2Bridge.Service
  805. const seen = yield* Deferred.make<{
  806. sessionID: SessionID
  807. requestID: PermissionV1.ID
  808. reply: PermissionV1.Reply
  809. }>()
  810. const fiber = yield* ask({
  811. id: PermissionV1.ID.make("per_test7"),
  812. sessionID: SessionID.make("session_test"),
  813. permission: "bash",
  814. patterns: ["ls"],
  815. metadata: {},
  816. always: [],
  817. ruleset: [],
  818. }).pipe(Effect.forkScoped)
  819. yield* waitForPending(1)
  820. const unsub = yield* events.listen((event) => {
  821. if (event.type === Permission.Event.Replied.type)
  822. Deferred.doneUnsafe(
  823. seen,
  824. Effect.succeed(
  825. event.data as { sessionID: SessionID; requestID: PermissionV1.ID; reply: PermissionV1.Reply },
  826. ),
  827. )
  828. return Effect.void
  829. })
  830. yield* Effect.addFinalizer(() => unsub)
  831. yield* reply({ requestID: PermissionV1.ID.make("per_test7"), reply: "once" })
  832. yield* Fiber.join(fiber)
  833. expect(
  834. yield* Deferred.await(seen).pipe(
  835. Effect.timeoutOrElse({
  836. duration: "1 second",
  837. orElse: () => Effect.fail(new Error("timed out waiting for permission replied event")),
  838. }),
  839. ),
  840. ).toEqual({
  841. sessionID: SessionID.make("session_test"),
  842. requestID: PermissionV1.ID.make("per_test7"),
  843. reply: "once",
  844. })
  845. }),
  846. { git: true },
  847. )
  848. it.live("permission requests stay isolated by directory", () =>
  849. Effect.gen(function* () {
  850. const one = yield* tmpdirScoped({ git: true })
  851. const two = yield* tmpdirScoped({ git: true })
  852. const store = yield* InstanceStore.Service
  853. const a = yield* store
  854. .provide(
  855. { directory: one },
  856. ask({
  857. id: PermissionV1.ID.make("per_dir_a"),
  858. sessionID: SessionID.make("session_dir_a"),
  859. permission: "bash",
  860. patterns: ["ls"],
  861. metadata: {},
  862. always: [],
  863. ruleset: [],
  864. }),
  865. )
  866. .pipe(Effect.forkScoped)
  867. const b = yield* store
  868. .provide(
  869. { directory: two },
  870. ask({
  871. id: PermissionV1.ID.make("per_dir_b"),
  872. sessionID: SessionID.make("session_dir_b"),
  873. permission: "bash",
  874. patterns: ["pwd"],
  875. metadata: {},
  876. always: [],
  877. ruleset: [],
  878. }),
  879. )
  880. .pipe(Effect.forkScoped)
  881. const onePending = yield* store.provide({ directory: one }, waitForPending(1))
  882. const twoPending = yield* store.provide({ directory: two }, waitForPending(1))
  883. expect(onePending).toHaveLength(1)
  884. expect(twoPending).toHaveLength(1)
  885. expect(onePending[0].id).toBe(PermissionV1.ID.make("per_dir_a"))
  886. expect(twoPending[0].id).toBe(PermissionV1.ID.make("per_dir_b"))
  887. yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" }))
  888. yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" }))
  889. yield* Fiber.await(a)
  890. yield* Fiber.await(b)
  891. }),
  892. )
  893. it.instance(
  894. "pending permission rejects on instance dispose",
  895. () =>
  896. Effect.gen(function* () {
  897. const test = yield* TestInstance
  898. const store = yield* InstanceStore.Service
  899. const fiber = yield* ask({
  900. id: PermissionV1.ID.make("per_dispose"),
  901. sessionID: SessionID.make("session_dispose"),
  902. permission: "bash",
  903. patterns: ["ls"],
  904. metadata: {},
  905. always: [],
  906. ruleset: [],
  907. }).pipe(Effect.forkScoped)
  908. expect(yield* waitForPending(1)).toHaveLength(1)
  909. const ctx = yield* store.load({ directory: test.directory })
  910. yield* store.dispose(ctx)
  911. const exit = yield* Fiber.await(fiber)
  912. expect(Exit.isFailure(exit)).toBe(true)
  913. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError)
  914. }),
  915. { git: true },
  916. )
  917. it.instance(
  918. "pending permission rejects on instance reload",
  919. () =>
  920. Effect.gen(function* () {
  921. const test = yield* TestInstance
  922. const store = yield* InstanceStore.Service
  923. const fiber = yield* ask({
  924. id: PermissionV1.ID.make("per_reload"),
  925. sessionID: SessionID.make("session_reload"),
  926. permission: "bash",
  927. patterns: ["ls"],
  928. metadata: {},
  929. always: [],
  930. ruleset: [],
  931. }).pipe(Effect.forkScoped)
  932. expect(yield* waitForPending(1)).toHaveLength(1)
  933. yield* store.reload({ directory: test.directory })
  934. const exit = yield* Fiber.await(fiber)
  935. expect(Exit.isFailure(exit)).toBe(true)
  936. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError)
  937. }),
  938. { git: true },
  939. )
  940. it.instance(
  941. "reply - fails for unknown requestID",
  942. () =>
  943. Effect.gen(function* () {
  944. const exit = yield* reply({ requestID: PermissionV1.ID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
  945. expect(Exit.isFailure(exit)).toBe(true)
  946. if (Exit.isFailure(exit)) {
  947. expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
  948. }
  949. expect(yield* list()).toHaveLength(0)
  950. }),
  951. { git: true },
  952. )
  953. it.instance(
  954. "ask - checks all patterns and stops on first deny",
  955. () =>
  956. Effect.gen(function* () {
  957. const err = yield* fail(
  958. ask({
  959. sessionID: SessionID.make("session_test"),
  960. permission: "bash",
  961. patterns: ["echo hello", "rm -rf /"],
  962. metadata: {},
  963. always: [],
  964. ruleset: [
  965. { permission: "bash", pattern: "*", action: "allow" },
  966. { permission: "bash", pattern: "rm *", action: "deny" },
  967. ],
  968. }),
  969. )
  970. expect(err).toBeInstanceOf(PermissionV1.DeniedError)
  971. }),
  972. { git: true },
  973. )
  974. it.instance(
  975. "ask - allows all patterns when all match allow rules",
  976. () =>
  977. Effect.gen(function* () {
  978. const result = yield* ask({
  979. sessionID: SessionID.make("session_test"),
  980. permission: "bash",
  981. patterns: ["echo hello", "ls -la", "pwd"],
  982. metadata: {},
  983. always: [],
  984. ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
  985. })
  986. expect(result).toBeUndefined()
  987. }),
  988. { git: true },
  989. )
  990. it.instance(
  991. "ask - should deny even when an earlier pattern is ask",
  992. () =>
  993. Effect.gen(function* () {
  994. const err = yield* fail(
  995. ask({
  996. sessionID: SessionID.make("session_test"),
  997. permission: "bash",
  998. patterns: ["echo hello", "rm -rf /"],
  999. metadata: {},
  1000. always: [],
  1001. ruleset: [
  1002. { permission: "bash", pattern: "echo *", action: "ask" },
  1003. { permission: "bash", pattern: "rm *", action: "deny" },
  1004. ],
  1005. }),
  1006. )
  1007. expect(err).toBeInstanceOf(PermissionV1.DeniedError)
  1008. expect(yield* list()).toHaveLength(0)
  1009. }),
  1010. { git: true },
  1011. )
  1012. it.instance(
  1013. "ask - abort should clear pending request",
  1014. () =>
  1015. Effect.gen(function* () {
  1016. const test = yield* TestInstance
  1017. const store = yield* InstanceStore.Service
  1018. const fiber = yield* ask({
  1019. id: PermissionV1.ID.make("per_reload"),
  1020. sessionID: SessionID.make("session_reload"),
  1021. permission: "bash",
  1022. patterns: ["ls"],
  1023. metadata: {},
  1024. always: [],
  1025. ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
  1026. }).pipe(Effect.forkScoped)
  1027. const pending = yield* waitForPending(1)
  1028. expect(pending).toHaveLength(1)
  1029. yield* store.reload({ directory: test.directory })
  1030. const exit = yield* Fiber.await(fiber)
  1031. expect(Exit.isFailure(exit)).toBe(true)
  1032. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError)
  1033. }),
  1034. { git: true },
  1035. )