next.test.ts 37 KB

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