next.test.ts 23 KB

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