next.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. import { test, expect } from "bun:test"
  2. import os from "os"
  3. import { PermissionNext } from "../../src/permission/next"
  4. import { Instance } from "../../src/project/instance"
  5. import { Storage } from "../../src/storage/storage"
  6. import { tmpdir } from "../fixture/fixture"
  7. // fromConfig tests
  8. test("fromConfig - string value becomes wildcard rule", () => {
  9. const result = PermissionNext.fromConfig({ bash: "allow" })
  10. expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
  11. })
  12. test("fromConfig - object value converts to rules array", () => {
  13. const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
  14. expect(result).toEqual([
  15. { permission: "bash", pattern: "*", action: "allow" },
  16. { permission: "bash", pattern: "rm", action: "deny" },
  17. ])
  18. })
  19. test("fromConfig - mixed string and object values", () => {
  20. const result = PermissionNext.fromConfig({
  21. bash: { "*": "allow", rm: "deny" },
  22. edit: "allow",
  23. webfetch: "ask",
  24. })
  25. expect(result).toEqual([
  26. { permission: "bash", pattern: "*", action: "allow" },
  27. { permission: "bash", pattern: "rm", action: "deny" },
  28. { permission: "edit", pattern: "*", action: "allow" },
  29. { permission: "webfetch", pattern: "*", action: "ask" },
  30. ])
  31. })
  32. test("fromConfig - empty object", () => {
  33. const result = PermissionNext.fromConfig({})
  34. expect(result).toEqual([])
  35. })
  36. test("fromConfig - expands tilde to home directory", () => {
  37. const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
  38. expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
  39. })
  40. test("fromConfig - expands $HOME to home directory", () => {
  41. const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
  42. expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
  43. })
  44. test("fromConfig - expands $HOME without trailing slash", () => {
  45. const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
  46. expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
  47. })
  48. test("fromConfig - does not expand tilde in middle of path", () => {
  49. const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
  50. expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
  51. })
  52. test("fromConfig - expands exact tilde to home directory", () => {
  53. const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
  54. expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
  55. })
  56. test("evaluate - matches expanded tilde pattern", () => {
  57. const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
  58. const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
  59. expect(result.action).toBe("allow")
  60. })
  61. test("evaluate - matches expanded $HOME pattern", () => {
  62. const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
  63. const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
  64. expect(result.action).toBe("allow")
  65. })
  66. // merge tests
  67. test("merge - simple concatenation", () => {
  68. const result = PermissionNext.merge(
  69. [{ permission: "bash", pattern: "*", action: "allow" }],
  70. [{ permission: "bash", pattern: "*", action: "deny" }],
  71. )
  72. expect(result).toEqual([
  73. { permission: "bash", pattern: "*", action: "allow" },
  74. { permission: "bash", pattern: "*", action: "deny" },
  75. ])
  76. })
  77. test("merge - adds new permission", () => {
  78. const result = PermissionNext.merge(
  79. [{ permission: "bash", pattern: "*", action: "allow" }],
  80. [{ permission: "edit", pattern: "*", action: "deny" }],
  81. )
  82. expect(result).toEqual([
  83. { permission: "bash", pattern: "*", action: "allow" },
  84. { permission: "edit", pattern: "*", action: "deny" },
  85. ])
  86. })
  87. test("merge - concatenates rules for same permission", () => {
  88. const result = PermissionNext.merge(
  89. [{ permission: "bash", pattern: "foo", action: "ask" }],
  90. [{ permission: "bash", pattern: "*", action: "deny" }],
  91. )
  92. expect(result).toEqual([
  93. { permission: "bash", pattern: "foo", action: "ask" },
  94. { permission: "bash", pattern: "*", action: "deny" },
  95. ])
  96. })
  97. test("merge - multiple rulesets", () => {
  98. const result = PermissionNext.merge(
  99. [{ permission: "bash", pattern: "*", action: "allow" }],
  100. [{ permission: "bash", pattern: "rm", action: "ask" }],
  101. [{ permission: "edit", pattern: "*", action: "allow" }],
  102. )
  103. expect(result).toEqual([
  104. { permission: "bash", pattern: "*", action: "allow" },
  105. { permission: "bash", pattern: "rm", action: "ask" },
  106. { permission: "edit", pattern: "*", action: "allow" },
  107. ])
  108. })
  109. test("merge - empty ruleset does nothing", () => {
  110. const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
  111. expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
  112. })
  113. test("merge - preserves rule order", () => {
  114. const result = PermissionNext.merge(
  115. [
  116. { permission: "edit", pattern: "src/*", action: "allow" },
  117. { permission: "edit", pattern: "src/secret/*", action: "deny" },
  118. ],
  119. [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
  120. )
  121. expect(result).toEqual([
  122. { permission: "edit", pattern: "src/*", action: "allow" },
  123. { permission: "edit", pattern: "src/secret/*", action: "deny" },
  124. { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
  125. ])
  126. })
  127. test("merge - config permission overrides default ask", () => {
  128. // Simulates: defaults have "*": "ask", config sets bash: "allow"
  129. const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
  130. const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
  131. const merged = PermissionNext.merge(defaults, config)
  132. // Config's bash allow should override default ask
  133. expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
  134. // Other permissions should still be ask (from defaults)
  135. expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
  136. })
  137. test("merge - config ask overrides default allow", () => {
  138. // Simulates: defaults have bash: "allow", config sets bash: "ask"
  139. const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
  140. const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
  141. const merged = PermissionNext.merge(defaults, config)
  142. // Config's ask should override default allow
  143. expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
  144. })
  145. // evaluate tests
  146. test("evaluate - exact pattern match", () => {
  147. const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
  148. expect(result.action).toBe("deny")
  149. })
  150. test("evaluate - wildcard pattern match", () => {
  151. const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
  152. expect(result.action).toBe("allow")
  153. })
  154. test("evaluate - last matching rule wins", () => {
  155. const result = PermissionNext.evaluate("bash", "rm", [
  156. { permission: "bash", pattern: "*", action: "allow" },
  157. { permission: "bash", pattern: "rm", action: "deny" },
  158. ])
  159. expect(result.action).toBe("deny")
  160. })
  161. test("evaluate - last matching rule wins (wildcard after specific)", () => {
  162. const result = PermissionNext.evaluate("bash", "rm", [
  163. { permission: "bash", pattern: "rm", action: "deny" },
  164. { permission: "bash", pattern: "*", action: "allow" },
  165. ])
  166. expect(result.action).toBe("allow")
  167. })
  168. test("evaluate - glob pattern match", () => {
  169. const result = PermissionNext.evaluate("edit", "src/foo.ts", [
  170. { permission: "edit", pattern: "src/*", action: "allow" },
  171. ])
  172. expect(result.action).toBe("allow")
  173. })
  174. test("evaluate - last matching glob wins", () => {
  175. const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
  176. { permission: "edit", pattern: "src/*", action: "deny" },
  177. { permission: "edit", pattern: "src/components/*", action: "allow" },
  178. ])
  179. expect(result.action).toBe("allow")
  180. })
  181. test("evaluate - order matters for specificity", () => {
  182. // If more specific rule comes first, later wildcard overrides it
  183. const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
  184. { permission: "edit", pattern: "src/components/*", action: "allow" },
  185. { permission: "edit", pattern: "src/*", action: "deny" },
  186. ])
  187. expect(result.action).toBe("deny")
  188. })
  189. test("evaluate - unknown permission returns ask", () => {
  190. const result = PermissionNext.evaluate("unknown_tool", "anything", [
  191. { permission: "bash", pattern: "*", action: "allow" },
  192. ])
  193. expect(result.action).toBe("ask")
  194. })
  195. test("evaluate - empty ruleset returns ask", () => {
  196. const result = PermissionNext.evaluate("bash", "rm", [])
  197. expect(result.action).toBe("ask")
  198. })
  199. test("evaluate - no matching pattern returns ask", () => {
  200. const result = PermissionNext.evaluate("edit", "etc/passwd", [
  201. { permission: "edit", pattern: "src/*", action: "allow" },
  202. ])
  203. expect(result.action).toBe("ask")
  204. })
  205. test("evaluate - empty rules array returns ask", () => {
  206. const result = PermissionNext.evaluate("bash", "rm", [])
  207. expect(result.action).toBe("ask")
  208. })
  209. test("evaluate - multiple matching patterns, last wins", () => {
  210. const result = PermissionNext.evaluate("edit", "src/secret.ts", [
  211. { permission: "edit", pattern: "*", action: "ask" },
  212. { permission: "edit", pattern: "src/*", action: "allow" },
  213. { permission: "edit", pattern: "src/secret.ts", action: "deny" },
  214. ])
  215. expect(result.action).toBe("deny")
  216. })
  217. test("evaluate - non-matching patterns are skipped", () => {
  218. const result = PermissionNext.evaluate("edit", "src/foo.ts", [
  219. { permission: "edit", pattern: "*", action: "ask" },
  220. { permission: "edit", pattern: "test/*", action: "deny" },
  221. { permission: "edit", pattern: "src/*", action: "allow" },
  222. ])
  223. expect(result.action).toBe("allow")
  224. })
  225. test("evaluate - exact match at end wins over earlier wildcard", () => {
  226. const result = PermissionNext.evaluate("bash", "/bin/rm", [
  227. { permission: "bash", pattern: "*", action: "allow" },
  228. { permission: "bash", pattern: "/bin/rm", action: "deny" },
  229. ])
  230. expect(result.action).toBe("deny")
  231. })
  232. test("evaluate - wildcard at end overrides earlier exact match", () => {
  233. const result = PermissionNext.evaluate("bash", "/bin/rm", [
  234. { permission: "bash", pattern: "/bin/rm", action: "deny" },
  235. { permission: "bash", pattern: "*", action: "allow" },
  236. ])
  237. expect(result.action).toBe("allow")
  238. })
  239. // wildcard permission tests
  240. test("evaluate - wildcard permission matches any permission", () => {
  241. const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
  242. expect(result.action).toBe("deny")
  243. })
  244. test("evaluate - wildcard permission with specific pattern", () => {
  245. const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
  246. expect(result.action).toBe("deny")
  247. })
  248. test("evaluate - glob permission pattern", () => {
  249. const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
  250. { permission: "mcp_*", pattern: "*", action: "allow" },
  251. ])
  252. expect(result.action).toBe("allow")
  253. })
  254. test("evaluate - specific permission and wildcard permission combined", () => {
  255. const result = PermissionNext.evaluate("bash", "rm", [
  256. { permission: "*", pattern: "*", action: "deny" },
  257. { permission: "bash", pattern: "*", action: "allow" },
  258. ])
  259. expect(result.action).toBe("allow")
  260. })
  261. test("evaluate - wildcard permission does not match when specific exists", () => {
  262. const result = PermissionNext.evaluate("edit", "src/foo.ts", [
  263. { permission: "*", pattern: "*", action: "deny" },
  264. { permission: "edit", pattern: "src/*", action: "allow" },
  265. ])
  266. expect(result.action).toBe("allow")
  267. })
  268. test("evaluate - multiple matching permission patterns combine rules", () => {
  269. const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
  270. { permission: "*", pattern: "*", action: "ask" },
  271. { permission: "mcp_*", pattern: "*", action: "allow" },
  272. { permission: "mcp_dangerous", pattern: "*", action: "deny" },
  273. ])
  274. expect(result.action).toBe("deny")
  275. })
  276. test("evaluate - wildcard permission fallback for unknown tool", () => {
  277. const result = PermissionNext.evaluate("unknown_tool", "anything", [
  278. { permission: "*", pattern: "*", action: "ask" },
  279. { permission: "bash", pattern: "*", action: "allow" },
  280. ])
  281. expect(result.action).toBe("ask")
  282. })
  283. test("evaluate - permission patterns sorted by length regardless of object order", () => {
  284. // specific permission listed before wildcard, but specific should still win
  285. const result = PermissionNext.evaluate("bash", "rm", [
  286. { permission: "bash", pattern: "*", action: "allow" },
  287. { permission: "*", pattern: "*", action: "deny" },
  288. ])
  289. // With flat list, last matching rule wins - so "*" matches bash and wins
  290. expect(result.action).toBe("deny")
  291. })
  292. test("evaluate - merges multiple rulesets", () => {
  293. const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
  294. const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
  295. // approved comes after config, so rm should be denied
  296. const result = PermissionNext.evaluate("bash", "rm", config, approved)
  297. expect(result.action).toBe("deny")
  298. })
  299. // disabled tests
  300. test("disabled - returns empty set when all tools allowed", () => {
  301. const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
  302. expect(result.size).toBe(0)
  303. })
  304. test("disabled - disables tool when denied", () => {
  305. const result = PermissionNext.disabled(
  306. ["bash", "edit", "read"],
  307. [
  308. { permission: "*", pattern: "*", action: "allow" },
  309. { permission: "bash", pattern: "*", action: "deny" },
  310. ],
  311. )
  312. expect(result.has("bash")).toBe(true)
  313. expect(result.has("edit")).toBe(false)
  314. expect(result.has("read")).toBe(false)
  315. })
  316. test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
  317. const result = PermissionNext.disabled(
  318. ["edit", "write", "patch", "multiedit", "bash"],
  319. [
  320. { permission: "*", pattern: "*", action: "allow" },
  321. { permission: "edit", pattern: "*", action: "deny" },
  322. ],
  323. )
  324. expect(result.has("edit")).toBe(true)
  325. expect(result.has("write")).toBe(true)
  326. expect(result.has("patch")).toBe(true)
  327. expect(result.has("multiedit")).toBe(true)
  328. expect(result.has("bash")).toBe(false)
  329. })
  330. test("disabled - does not disable when partially denied", () => {
  331. const result = PermissionNext.disabled(
  332. ["bash"],
  333. [
  334. { permission: "bash", pattern: "*", action: "allow" },
  335. { permission: "bash", pattern: "rm *", action: "deny" },
  336. ],
  337. )
  338. expect(result.has("bash")).toBe(false)
  339. })
  340. test("disabled - does not disable when action is ask", () => {
  341. const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
  342. expect(result.size).toBe(0)
  343. })
  344. test("disabled - does not disable when specific allow after wildcard deny", () => {
  345. // Tool is NOT disabled because a specific allow after wildcard deny means
  346. // there's at least some usage allowed
  347. const result = PermissionNext.disabled(
  348. ["bash"],
  349. [
  350. { permission: "bash", pattern: "*", action: "deny" },
  351. { permission: "bash", pattern: "echo *", action: "allow" },
  352. ],
  353. )
  354. expect(result.has("bash")).toBe(false)
  355. })
  356. test("disabled - does not disable when wildcard allow after deny", () => {
  357. const result = PermissionNext.disabled(
  358. ["bash"],
  359. [
  360. { permission: "bash", pattern: "rm *", action: "deny" },
  361. { permission: "bash", pattern: "*", action: "allow" },
  362. ],
  363. )
  364. expect(result.has("bash")).toBe(false)
  365. })
  366. test("disabled - disables multiple tools", () => {
  367. const result = PermissionNext.disabled(
  368. ["bash", "edit", "webfetch"],
  369. [
  370. { permission: "bash", pattern: "*", action: "deny" },
  371. { permission: "edit", pattern: "*", action: "deny" },
  372. { permission: "webfetch", pattern: "*", action: "deny" },
  373. ],
  374. )
  375. expect(result.has("bash")).toBe(true)
  376. expect(result.has("edit")).toBe(true)
  377. expect(result.has("webfetch")).toBe(true)
  378. })
  379. test("disabled - wildcard permission denies all tools", () => {
  380. const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
  381. expect(result.has("bash")).toBe(true)
  382. expect(result.has("edit")).toBe(true)
  383. expect(result.has("read")).toBe(true)
  384. })
  385. test("disabled - specific allow overrides wildcard deny", () => {
  386. const result = PermissionNext.disabled(
  387. ["bash", "edit", "read"],
  388. [
  389. { permission: "*", pattern: "*", action: "deny" },
  390. { permission: "bash", pattern: "*", action: "allow" },
  391. ],
  392. )
  393. expect(result.has("bash")).toBe(false)
  394. expect(result.has("edit")).toBe(true)
  395. expect(result.has("read")).toBe(true)
  396. })
  397. // ask tests
  398. test("ask - resolves immediately when action is allow", async () => {
  399. await using tmp = await tmpdir({ git: true })
  400. await Instance.provide({
  401. directory: tmp.path,
  402. fn: async () => {
  403. const result = await PermissionNext.ask({
  404. sessionID: "session_test",
  405. permission: "bash",
  406. patterns: ["ls"],
  407. metadata: {},
  408. always: [],
  409. ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
  410. })
  411. expect(result).toBeUndefined()
  412. },
  413. })
  414. })
  415. test("ask - throws RejectedError when action is deny", async () => {
  416. await using tmp = await tmpdir({ git: true })
  417. await Instance.provide({
  418. directory: tmp.path,
  419. fn: async () => {
  420. await expect(
  421. PermissionNext.ask({
  422. sessionID: "session_test",
  423. permission: "bash",
  424. patterns: ["rm -rf /"],
  425. metadata: {},
  426. always: [],
  427. ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
  428. }),
  429. ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
  430. },
  431. })
  432. })
  433. test("ask - returns pending promise when action is ask", async () => {
  434. await using tmp = await tmpdir({ git: true })
  435. await Instance.provide({
  436. directory: tmp.path,
  437. fn: async () => {
  438. const promise = PermissionNext.ask({
  439. sessionID: "session_test",
  440. permission: "bash",
  441. patterns: ["ls"],
  442. metadata: {},
  443. always: [],
  444. ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
  445. })
  446. // Promise should be pending, not resolved
  447. expect(promise).toBeInstanceOf(Promise)
  448. // Don't await - just verify it returns a promise
  449. },
  450. })
  451. })
  452. // reply tests
  453. test("reply - once resolves the pending ask", async () => {
  454. await using tmp = await tmpdir({ git: true })
  455. await Instance.provide({
  456. directory: tmp.path,
  457. fn: async () => {
  458. const askPromise = PermissionNext.ask({
  459. id: "permission_test1",
  460. sessionID: "session_test",
  461. permission: "bash",
  462. patterns: ["ls"],
  463. metadata: {},
  464. always: [],
  465. ruleset: [],
  466. })
  467. await PermissionNext.reply({
  468. requestID: "permission_test1",
  469. reply: "once",
  470. })
  471. await expect(askPromise).resolves.toBeUndefined()
  472. },
  473. })
  474. })
  475. test("reply - reject throws RejectedError", async () => {
  476. await using tmp = await tmpdir({ git: true })
  477. await Instance.provide({
  478. directory: tmp.path,
  479. fn: async () => {
  480. const askPromise = PermissionNext.ask({
  481. id: "permission_test2",
  482. sessionID: "session_test",
  483. permission: "bash",
  484. patterns: ["ls"],
  485. metadata: {},
  486. always: [],
  487. ruleset: [],
  488. })
  489. await PermissionNext.reply({
  490. requestID: "permission_test2",
  491. reply: "reject",
  492. })
  493. await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
  494. },
  495. })
  496. })
  497. test("reply - always persists approval and resolves", async () => {
  498. await using tmp = await tmpdir({ git: true })
  499. await Instance.provide({
  500. directory: tmp.path,
  501. fn: async () => {
  502. const askPromise = PermissionNext.ask({
  503. id: "permission_test3",
  504. sessionID: "session_test",
  505. permission: "bash",
  506. patterns: ["ls"],
  507. metadata: {},
  508. always: ["ls"],
  509. ruleset: [],
  510. })
  511. await PermissionNext.reply({
  512. requestID: "permission_test3",
  513. reply: "always",
  514. })
  515. await expect(askPromise).resolves.toBeUndefined()
  516. },
  517. })
  518. // Re-provide to reload state with stored permissions
  519. await Instance.provide({
  520. directory: tmp.path,
  521. fn: async () => {
  522. // Stored approval should allow without asking
  523. const result = await PermissionNext.ask({
  524. sessionID: "session_test2",
  525. permission: "bash",
  526. patterns: ["ls"],
  527. metadata: {},
  528. always: [],
  529. ruleset: [],
  530. })
  531. expect(result).toBeUndefined()
  532. },
  533. })
  534. })
  535. test("reply - reject cancels all pending for same session", async () => {
  536. await using tmp = await tmpdir({ git: true })
  537. await Instance.provide({
  538. directory: tmp.path,
  539. fn: async () => {
  540. const askPromise1 = PermissionNext.ask({
  541. id: "permission_test4a",
  542. sessionID: "session_same",
  543. permission: "bash",
  544. patterns: ["ls"],
  545. metadata: {},
  546. always: [],
  547. ruleset: [],
  548. })
  549. const askPromise2 = PermissionNext.ask({
  550. id: "permission_test4b",
  551. sessionID: "session_same",
  552. permission: "edit",
  553. patterns: ["foo.ts"],
  554. metadata: {},
  555. always: [],
  556. ruleset: [],
  557. })
  558. // Catch rejections before they become unhandled
  559. const result1 = askPromise1.catch((e) => e)
  560. const result2 = askPromise2.catch((e) => e)
  561. // Reject the first one
  562. await PermissionNext.reply({
  563. requestID: "permission_test4a",
  564. reply: "reject",
  565. })
  566. // Both should be rejected
  567. expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
  568. expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
  569. },
  570. })
  571. })
  572. test("ask - checks all patterns and stops on first deny", async () => {
  573. await using tmp = await tmpdir({ git: true })
  574. await Instance.provide({
  575. directory: tmp.path,
  576. fn: async () => {
  577. await expect(
  578. PermissionNext.ask({
  579. sessionID: "session_test",
  580. permission: "bash",
  581. patterns: ["echo hello", "rm -rf /"],
  582. metadata: {},
  583. always: [],
  584. ruleset: [
  585. { permission: "bash", pattern: "*", action: "allow" },
  586. { permission: "bash", pattern: "rm *", action: "deny" },
  587. ],
  588. }),
  589. ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
  590. },
  591. })
  592. })
  593. test("ask - allows all patterns when all match allow rules", async () => {
  594. await using tmp = await tmpdir({ git: true })
  595. await Instance.provide({
  596. directory: tmp.path,
  597. fn: async () => {
  598. const result = await PermissionNext.ask({
  599. sessionID: "session_test",
  600. permission: "bash",
  601. patterns: ["echo hello", "ls -la", "pwd"],
  602. metadata: {},
  603. always: [],
  604. ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
  605. })
  606. expect(result).toBeUndefined()
  607. },
  608. })
  609. })