next.test.ts 37 KB

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