| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652 |
- import { test, expect } from "bun:test"
- import { PermissionNext } from "../../src/permission/next"
- import { Instance } from "../../src/project/instance"
- import { Storage } from "../../src/storage/storage"
- import { tmpdir } from "../fixture/fixture"
- // fromConfig tests
- test("fromConfig - string value becomes wildcard rule", () => {
- const result = PermissionNext.fromConfig({ bash: "allow" })
- expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
- })
- test("fromConfig - object value converts to rules array", () => {
- const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
- expect(result).toEqual([
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm", action: "deny" },
- ])
- })
- test("fromConfig - mixed string and object values", () => {
- const result = PermissionNext.fromConfig({
- bash: { "*": "allow", rm: "deny" },
- edit: "allow",
- webfetch: "ask",
- })
- expect(result).toEqual([
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm", action: "deny" },
- { permission: "edit", pattern: "*", action: "allow" },
- { permission: "webfetch", pattern: "*", action: "ask" },
- ])
- })
- test("fromConfig - empty object", () => {
- const result = PermissionNext.fromConfig({})
- expect(result).toEqual([])
- })
- // merge tests
- test("merge - simple concatenation", () => {
- const result = PermissionNext.merge(
- [{ permission: "bash", pattern: "*", action: "allow" }],
- [{ permission: "bash", pattern: "*", action: "deny" }],
- )
- expect(result).toEqual([
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "*", action: "deny" },
- ])
- })
- test("merge - adds new permission", () => {
- const result = PermissionNext.merge(
- [{ permission: "bash", pattern: "*", action: "allow" }],
- [{ permission: "edit", pattern: "*", action: "deny" }],
- )
- expect(result).toEqual([
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "edit", pattern: "*", action: "deny" },
- ])
- })
- test("merge - concatenates rules for same permission", () => {
- const result = PermissionNext.merge(
- [{ permission: "bash", pattern: "foo", action: "ask" }],
- [{ permission: "bash", pattern: "*", action: "deny" }],
- )
- expect(result).toEqual([
- { permission: "bash", pattern: "foo", action: "ask" },
- { permission: "bash", pattern: "*", action: "deny" },
- ])
- })
- test("merge - multiple rulesets", () => {
- const result = PermissionNext.merge(
- [{ permission: "bash", pattern: "*", action: "allow" }],
- [{ permission: "bash", pattern: "rm", action: "ask" }],
- [{ permission: "edit", pattern: "*", action: "allow" }],
- )
- expect(result).toEqual([
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm", action: "ask" },
- { permission: "edit", pattern: "*", action: "allow" },
- ])
- })
- test("merge - empty ruleset does nothing", () => {
- const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
- expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
- })
- test("merge - preserves rule order", () => {
- const result = PermissionNext.merge(
- [
- { permission: "edit", pattern: "src/*", action: "allow" },
- { permission: "edit", pattern: "src/secret/*", action: "deny" },
- ],
- [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
- )
- expect(result).toEqual([
- { permission: "edit", pattern: "src/*", action: "allow" },
- { permission: "edit", pattern: "src/secret/*", action: "deny" },
- { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
- ])
- })
- test("merge - config permission overrides default ask", () => {
- // Simulates: defaults have "*": "ask", config sets bash: "allow"
- const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
- const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
- const merged = PermissionNext.merge(defaults, config)
- // Config's bash allow should override default ask
- expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow")
- // Other permissions should still be ask (from defaults)
- expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask")
- })
- test("merge - config ask overrides default allow", () => {
- // Simulates: defaults have bash: "allow", config sets bash: "ask"
- const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
- const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
- const merged = PermissionNext.merge(defaults, config)
- // Config's ask should override default allow
- expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask")
- })
- // evaluate tests
- test("evaluate - exact pattern match", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
- expect(result).toBe("deny")
- })
- test("evaluate - wildcard pattern match", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
- expect(result).toBe("allow")
- })
- test("evaluate - last matching rule wins", () => {
- const result = PermissionNext.evaluate("bash", "rm", [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm", action: "deny" },
- ])
- expect(result).toBe("deny")
- })
- test("evaluate - last matching rule wins (wildcard after specific)", () => {
- const result = PermissionNext.evaluate("bash", "rm", [
- { permission: "bash", pattern: "rm", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - glob pattern match", () => {
- const result = PermissionNext.evaluate("edit", "src/foo.ts", [
- { permission: "edit", pattern: "src/*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - last matching glob wins", () => {
- const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
- { permission: "edit", pattern: "src/*", action: "deny" },
- { permission: "edit", pattern: "src/components/*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - order matters for specificity", () => {
- // If more specific rule comes first, later wildcard overrides it
- const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
- { permission: "edit", pattern: "src/components/*", action: "allow" },
- { permission: "edit", pattern: "src/*", action: "deny" },
- ])
- expect(result).toBe("deny")
- })
- test("evaluate - unknown permission returns ask", () => {
- const result = PermissionNext.evaluate("unknown_tool", "anything", [
- { permission: "bash", pattern: "*", action: "allow" },
- ])
- expect(result).toBe("ask")
- })
- test("evaluate - empty ruleset returns ask", () => {
- const result = PermissionNext.evaluate("bash", "rm", [])
- expect(result).toBe("ask")
- })
- test("evaluate - no matching pattern returns ask", () => {
- const result = PermissionNext.evaluate("edit", "etc/passwd", [
- { permission: "edit", pattern: "src/*", action: "allow" },
- ])
- expect(result).toBe("ask")
- })
- test("evaluate - empty rules array returns ask", () => {
- const result = PermissionNext.evaluate("bash", "rm", [])
- expect(result).toBe("ask")
- })
- test("evaluate - multiple matching patterns, last wins", () => {
- const result = PermissionNext.evaluate("edit", "src/secret.ts", [
- { permission: "edit", pattern: "*", action: "ask" },
- { permission: "edit", pattern: "src/*", action: "allow" },
- { permission: "edit", pattern: "src/secret.ts", action: "deny" },
- ])
- expect(result).toBe("deny")
- })
- test("evaluate - non-matching patterns are skipped", () => {
- const result = PermissionNext.evaluate("edit", "src/foo.ts", [
- { permission: "edit", pattern: "*", action: "ask" },
- { permission: "edit", pattern: "test/*", action: "deny" },
- { permission: "edit", pattern: "src/*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - exact match at end wins over earlier wildcard", () => {
- const result = PermissionNext.evaluate("bash", "/bin/rm", [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "/bin/rm", action: "deny" },
- ])
- expect(result).toBe("deny")
- })
- test("evaluate - wildcard at end overrides earlier exact match", () => {
- const result = PermissionNext.evaluate("bash", "/bin/rm", [
- { permission: "bash", pattern: "/bin/rm", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- // wildcard permission tests
- test("evaluate - wildcard permission matches any permission", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
- expect(result).toBe("deny")
- })
- test("evaluate - wildcard permission with specific pattern", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
- expect(result).toBe("deny")
- })
- test("evaluate - glob permission pattern", () => {
- const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
- { permission: "mcp_*", pattern: "*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - specific permission and wildcard permission combined", () => {
- const result = PermissionNext.evaluate("bash", "rm", [
- { permission: "*", pattern: "*", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - wildcard permission does not match when specific exists", () => {
- const result = PermissionNext.evaluate("edit", "src/foo.ts", [
- { permission: "*", pattern: "*", action: "deny" },
- { permission: "edit", pattern: "src/*", action: "allow" },
- ])
- expect(result).toBe("allow")
- })
- test("evaluate - multiple matching permission patterns combine rules", () => {
- const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
- { permission: "*", pattern: "*", action: "ask" },
- { permission: "mcp_*", pattern: "*", action: "allow" },
- { permission: "mcp_dangerous", pattern: "*", action: "deny" },
- ])
- expect(result).toBe("deny")
- })
- test("evaluate - wildcard permission fallback for unknown tool", () => {
- const result = PermissionNext.evaluate("unknown_tool", "anything", [
- { permission: "*", pattern: "*", action: "ask" },
- { permission: "bash", pattern: "*", action: "allow" },
- ])
- expect(result).toBe("ask")
- })
- test("evaluate - permission patterns sorted by length regardless of object order", () => {
- // specific permission listed before wildcard, but specific should still win
- const result = PermissionNext.evaluate("bash", "rm", [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "*", pattern: "*", action: "deny" },
- ])
- // With flat list, last matching rule wins - so "*" matches bash and wins
- expect(result).toBe("deny")
- })
- test("evaluate - merges multiple rulesets", () => {
- const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
- const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
- // approved comes after config, so rm should be denied
- const result = PermissionNext.evaluate("bash", "rm", config, approved)
- expect(result).toBe("deny")
- })
- // disabled tests
- test("disabled - returns empty set when all tools allowed", () => {
- const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
- expect(result.size).toBe(0)
- })
- test("disabled - disables tool when denied", () => {
- const result = PermissionNext.disabled(
- ["bash", "edit", "read"],
- [
- { permission: "*", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "*", action: "deny" },
- ],
- )
- expect(result.has("bash")).toBe(true)
- expect(result.has("edit")).toBe(false)
- expect(result.has("read")).toBe(false)
- })
- test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
- const result = PermissionNext.disabled(
- ["edit", "write", "patch", "multiedit", "bash"],
- [
- { permission: "*", pattern: "*", action: "allow" },
- { permission: "edit", pattern: "*", action: "deny" },
- ],
- )
- expect(result.has("edit")).toBe(true)
- expect(result.has("write")).toBe(true)
- expect(result.has("patch")).toBe(true)
- expect(result.has("multiedit")).toBe(true)
- expect(result.has("bash")).toBe(false)
- })
- test("disabled - does not disable when partially denied", () => {
- const result = PermissionNext.disabled(
- ["bash"],
- [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm *", action: "deny" },
- ],
- )
- expect(result.has("bash")).toBe(false)
- })
- test("disabled - does not disable when action is ask", () => {
- const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
- expect(result.size).toBe(0)
- })
- test("disabled - disables when wildcard deny even with specific allow", () => {
- // Tool is disabled because evaluate("bash", "*", ...) returns "deny"
- // The "echo *" allow rule doesn't match the "*" pattern we're checking
- const result = PermissionNext.disabled(
- ["bash"],
- [
- { permission: "bash", pattern: "*", action: "deny" },
- { permission: "bash", pattern: "echo *", action: "allow" },
- ],
- )
- expect(result.has("bash")).toBe(true)
- })
- test("disabled - does not disable when wildcard allow after deny", () => {
- const result = PermissionNext.disabled(
- ["bash"],
- [
- { permission: "bash", pattern: "rm *", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ],
- )
- expect(result.has("bash")).toBe(false)
- })
- test("disabled - disables multiple tools", () => {
- const result = PermissionNext.disabled(
- ["bash", "edit", "webfetch"],
- [
- { permission: "bash", pattern: "*", action: "deny" },
- { permission: "edit", pattern: "*", action: "deny" },
- { permission: "webfetch", pattern: "*", action: "deny" },
- ],
- )
- expect(result.has("bash")).toBe(true)
- expect(result.has("edit")).toBe(true)
- expect(result.has("webfetch")).toBe(true)
- })
- test("disabled - wildcard permission denies all tools", () => {
- const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
- expect(result.has("bash")).toBe(true)
- expect(result.has("edit")).toBe(true)
- expect(result.has("read")).toBe(true)
- })
- test("disabled - specific allow overrides wildcard deny", () => {
- const result = PermissionNext.disabled(
- ["bash", "edit", "read"],
- [
- { permission: "*", pattern: "*", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ],
- )
- expect(result.has("bash")).toBe(false)
- expect(result.has("edit")).toBe(true)
- expect(result.has("read")).toBe(true)
- })
- // ask tests
- test("ask - resolves immediately when action is allow", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const result = await PermissionNext.ask({
- sessionID: "session_test",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: [],
- ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
- })
- expect(result).toBeUndefined()
- },
- })
- })
- test("ask - throws RejectedError when action is deny", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- await expect(
- PermissionNext.ask({
- sessionID: "session_test",
- permission: "bash",
- patterns: ["rm -rf /"],
- metadata: {},
- always: [],
- ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
- }),
- ).rejects.toBeInstanceOf(PermissionNext.RejectedError)
- },
- })
- })
- test("ask - returns pending promise when action is ask", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const promise = PermissionNext.ask({
- sessionID: "session_test",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: [],
- ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
- })
- // Promise should be pending, not resolved
- expect(promise).toBeInstanceOf(Promise)
- // Don't await - just verify it returns a promise
- },
- })
- })
- // reply tests
- test("reply - once resolves the pending ask", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const askPromise = PermissionNext.ask({
- id: "permission_test1",
- sessionID: "session_test",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: [],
- ruleset: [],
- })
- await PermissionNext.reply({
- requestID: "permission_test1",
- reply: "once",
- })
- await expect(askPromise).resolves.toBeUndefined()
- },
- })
- })
- test("reply - reject throws RejectedError", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const askPromise = PermissionNext.ask({
- id: "permission_test2",
- sessionID: "session_test",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: [],
- ruleset: [],
- })
- await PermissionNext.reply({
- requestID: "permission_test2",
- reply: "reject",
- })
- await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
- },
- })
- })
- test("reply - always persists approval and resolves", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const askPromise = PermissionNext.ask({
- id: "permission_test3",
- sessionID: "session_test",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: ["ls"],
- ruleset: [],
- })
- await PermissionNext.reply({
- requestID: "permission_test3",
- reply: "always",
- })
- await expect(askPromise).resolves.toBeUndefined()
- },
- })
- // Re-provide to reload state with stored permissions
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- // Stored approval should allow without asking
- const result = await PermissionNext.ask({
- sessionID: "session_test2",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: [],
- ruleset: [],
- })
- expect(result).toBeUndefined()
- },
- })
- })
- test("reply - reject cancels all pending for same session", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const askPromise1 = PermissionNext.ask({
- id: "permission_test4a",
- sessionID: "session_same",
- permission: "bash",
- patterns: ["ls"],
- metadata: {},
- always: [],
- ruleset: [],
- })
- const askPromise2 = PermissionNext.ask({
- id: "permission_test4b",
- sessionID: "session_same",
- permission: "edit",
- patterns: ["foo.ts"],
- metadata: {},
- always: [],
- ruleset: [],
- })
- // Catch rejections before they become unhandled
- const result1 = askPromise1.catch((e) => e)
- const result2 = askPromise2.catch((e) => e)
- // Reject the first one
- await PermissionNext.reply({
- requestID: "permission_test4a",
- reply: "reject",
- })
- // Both should be rejected
- expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
- expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
- },
- })
- })
- test("ask - checks all patterns and stops on first deny", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- await expect(
- PermissionNext.ask({
- sessionID: "session_test",
- permission: "bash",
- patterns: ["echo hello", "rm -rf /"],
- metadata: {},
- always: [],
- ruleset: [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm *", action: "deny" },
- ],
- }),
- ).rejects.toBeInstanceOf(PermissionNext.RejectedError)
- },
- })
- })
- test("ask - allows all patterns when all match allow rules", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const result = await PermissionNext.ask({
- sessionID: "session_test",
- permission: "bash",
- patterns: ["echo hello", "ls -la", "pwd"],
- metadata: {},
- always: [],
- ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
- })
- expect(result).toBeUndefined()
- },
- })
- })
|