opencode/packages/opencode/test/permission/next.test.ts
Kit Langton fce074726f refactor(core): migrate ConfigPermission.Info to Effect Schema canonical
Follow-up to #23716. Moves ConfigPermission.Info from zod-first (with a
preprocess hack) to Effect Schema canonical using Schema.StructWithRest +
Schema.decodeTo, and deletes the now-unused ZodPreprocess plumbing.

Core change: rule precedence in `Permission.fromConfig` now sorts top-level
keys so wildcard permissions (e.g. `*`, `mcp_*`) come before specific
ones (e.g. `bash`, `edit`). Combined with `findLast` in evaluate(),
this gives the intuitive semantic 'specific tool rules override the `*`
fallback' regardless of the user's JSON key order. This silently fixes the
previously-broken case `{bash: "allow", "*": "deny"}` (which under
the old semantics denied bash because `*` came last).

Once rule precedence no longer depends on JSON insertion order, the
`__originalKeys` + ZodPreprocess hack can go — StructWithRest's natural
canonicalisation is fine because fromConfig sorts anyway.

- src/config/permission.ts: rewrite. InputObject is StructWithRest with known
  permission keys (read/edit/bash/... as Rule, todowrite/webfetch/... as
  Action-only for type narrowing) + Record rest. Schema.decodeTo normalises
  the Action shorthand into { "*": action }. .zod is derived — walker
  already carries the decodeTo transform.
- src/config/config.ts, src/config/agent.ts: reference ConfigPermission.Info
  directly instead of via Schema.Any + ZodOverride. The Effect decoder now
  applies the permission transform at load time.
- src/permission/index.ts: fromConfig sorts wildcards-before-specifics at
  top level. Sub-pattern order inside a tool key is preserved (documented
  `*` first, specifics after).
- src/util/effect-zod.ts: delete ZodPreprocess symbol, its walkUncached
  branch, and the TODO comment. Zero remaining consumers.
- test/permission/next.test.ts: 6 new tests pinning the new semantics —
  order-independent precedence, wildcard-as-fallback, sub-pattern order
  preservation, canonical documented-example regression guard.
- test/config/config.test.ts: updated the "preserves key order" test to
  reflect the new canonical output shape (declaration-order known fields,
  then input-order rest keys). Behavioural guarantees live in the new
  permission tests.
- test/util/effect-zod.test.ts: delete the ZodPreprocess describe block
  (~115 lines of tests for the now-removed feature).

SDK diff vs dev:
- Removed `__originalKeys?: Array<string>` (internal leak).
- Catchall cleaned up (no unrelated `Array<string>`).
- Known-field types preserved (autocomplete + narrowing).
- Only shape change: PermissionConfig union order swap (commutative).

Safety audit: no config, test, or doc in the repo (including all 16
translations) exercises the pattern where specifics come before wildcards
at the top level. The only configs whose behaviour changes are ones that
were silently broken.
2026-04-21 17:38:24 -04:00

1140 lines
38 KiB
TypeScript

import { afterEach, test, expect } from "bun:test"
import os from "os"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Bus } from "../../src/bus"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
const bus = Bus.layer
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const it = testEffect(env)
afterEach(async () => {
await Instance.disposeAll()
})
const rejectAll = (message?: string) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
for (const req of yield* permission.list()) {
yield* permission.reply({
requestID: req.id,
reply: "reject",
message,
})
}
})
const waitForPending = (count: number) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
for (let i = 0; i < 100; i++) {
const list = yield* permission.list()
if (list.length === count) return list
yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`))
})
const fail = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) return Cause.squash(exit.cause)
throw new Error("expected permission effect to fail")
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.ask(input)
})
const reply = (input: Parameters<Permission.Interface["reply"]>[0]) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.reply(input)
})
const list = () =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.list()
})
function withDir(options: { git?: boolean } | undefined, self: (dir: string) => Effect.Effect<any, any, any>) {
return provideTmpdirInstance(self, options)
}
function withProvided(dir: string) {
return <A, E, R>(self: Effect.Effect<A, E, R>) => self.pipe(provideInstance(dir))
}
// fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => {
const result = Permission.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("fromConfig - object value converts to rules array", () => {
const result = Permission.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 = Permission.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 = Permission.fromConfig({})
expect(result).toEqual([])
})
test("fromConfig - expands tilde to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME without trailing slash", () => {
const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("fromConfig - does not expand tilde in middle of path", () => {
const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
// Top-level wildcard-vs-specific precedence semantics.
//
// fromConfig sorts top-level keys so wildcard permissions (containing "*")
// come before specific permissions. Combined with `findLast` in evaluate(),
// this gives the intuitive semantic "specific tool rules override the `*`
// fallback", regardless of the order the user wrote the keys in their JSON.
//
// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`)
// still depends on insertion order — only top-level keys are sorted.
test("fromConfig - specific key beats wildcard regardless of JSON key order", () => {
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
// Both orderings produce the same ruleset
expect(wildcardFirst).toEqual(specificFirst)
// And both evaluate bash → allow (bash rule wins over * fallback)
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
})
test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
})
test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
const ruleset = Permission.fromConfig({
bash: "allow",
"*": "ask",
edit: "deny",
"mcp_*": "allow",
})
// wildcards (* and mcp_*) come before specifics (bash, edit)
const permissions = ruleset.map((r) => r.permission)
expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"])
expect(permissions.slice(2)).toEqual(["bash", "edit"])
})
test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => {
// Sub-patterns within a single tool key use the documented "`*` first,
// specific patterns after" convention (findLast picks specifics). The
// top-level sort must not touch sub-pattern ordering.
const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
// * fallback for unknown commands
expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
// specific pattern wins for git commands (it's last, findLast picks it)
expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
})
test("fromConfig - canonical documented example unchanged", () => {
// Regression guard for the example in docs/permissions.mdx
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask")
})
test("fromConfig - expands exact tilde to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("evaluate - matches expanded tilde pattern", () => {
const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
test("evaluate - matches expanded $HOME pattern", () => {
const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
// merge tests
test("merge - simple concatenation", () => {
const result = Permission.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 = Permission.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 = Permission.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 = Permission.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 = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("merge - preserves rule order", () => {
const result = Permission.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", () => {
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
})
test("merge - config ask overrides default allow", () => {
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
})
// evaluate tests
test("evaluate - exact pattern match", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching rule wins", () => {
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
])
expect(result.action).toBe("deny")
})
test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - glob pattern match", () => {
const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching glob wins", () => {
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - order matters for specificity", () => {
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" },
])
expect(result.action).toBe("deny")
})
test("evaluate - unknown permission returns ask", () => {
const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("ask")
})
test("evaluate - empty ruleset returns ask", () => {
const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - no matching pattern returns ask", () => {
const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
expect(result.action).toBe("ask")
})
test("evaluate - empty rules array returns ask", () => {
const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - multiple matching patterns, last wins", () => {
const result = Permission.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.action).toBe("deny")
})
test("evaluate - non-matching patterns are skipped", () => {
const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
// wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard permission with specific pattern", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - glob permission pattern", () => {
const result = Permission.evaluate("mcp_server_tool", "anything", [
{ permission: "mcp_*", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - specific permission and wildcard permission combined", () => {
const result = Permission.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - wildcard permission does not match when specific exists", () => {
const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - multiple matching permission patterns combine rules", () => {
const result = Permission.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("ask")
})
test("evaluate - permission patterns sorted by length regardless of object order", () => {
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
])
expect(result.action).toBe("deny")
})
test("evaluate - merges multiple rulesets", () => {
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
// disabled tests
test("disabled - returns empty set when all tools allowed", () => {
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0)
})
test("disabled - disables tool when denied", () => {
const result = Permission.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/apply_patch when edit denied", () => {
const result = Permission.disabled(
["edit", "write", "apply_patch", "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("apply_patch")).toBe(true)
expect(result.has("bash")).toBe(false)
})
test("disabled - does not disable when partially denied", () => {
const result = Permission.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 = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0)
})
test("disabled - does not disable when specific allow after wildcard deny", () => {
const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "echo *", action: "allow" },
],
)
expect(result.has("bash")).toBe(false)
})
test("disabled - does not disable when wildcard allow after deny", () => {
const result = Permission.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 = Permission.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 = Permission.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 = Permission.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
it.live("ask - resolves immediately when action is allow", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const result = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
}),
),
)
it.live("ask - throws DeniedError when action is deny", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
}),
),
)
it.live("ask - stays pending when action is ask", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}).pipe(Effect.forkScoped)
expect(yield* waitForPending(1)).toHaveLength(1)
yield* rejectAll()
yield* Fiber.await(fiber)
}),
),
)
it.live("ask - adds request to pending list", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
}).pipe(Effect.forkScoped)
const items = yield* waitForPending(1)
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
})
yield* rejectAll()
yield* Fiber.await(fiber)
}),
),
)
it.live("ask - publishes asked event", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
let seen: Permission.Request | undefined
const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => {
seen = event.properties
})
try {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
}).pipe(Effect.forkScoped)
expect(yield* waitForPending(1)).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
})
yield* rejectAll()
yield* Fiber.await(fiber)
} finally {
unsub()
}
}),
),
)
// reply tests
it.live("reply - once resolves the pending ask", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" })
yield* Fiber.join(fiber)
}),
),
)
it.live("reply - reject throws RejectedError", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
),
)
it.live("reply - reject with message throws CorrectedError", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({
requestID: PermissionID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(String(err)).toContain("Use a safer command")
}
}),
),
)
it.live("reply - always persists approval and resolves", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
}).pipe(run, Effect.forkScoped)
yield* waitForPending(1).pipe(run)
yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }).pipe(run)
yield* Fiber.join(fiber)
const result = yield* ask({
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(run)
expect(result).toBeUndefined()
}),
)
it.live("reply - reject cancels all pending for same session", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
patterns: ["foo.ts"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" })
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isFailure(ea)).toBe(true)
expect(Exit.isFailure(eb)).toBe(true)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError)
}),
),
)
it.live("reply - always resolves matching pending requests in same session", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" })
yield* Fiber.join(a)
yield* Fiber.join(b)
expect(yield* list()).toHaveLength(0)
}),
),
)
it.live("reply - always keeps other session pending", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" })
yield* Fiber.join(a)
expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")])
yield* rejectAll()
yield* Fiber.await(b)
}),
),
)
it.live("reply - publishes replied event", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
let resolve!: (value: { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }) => void
const seen = Effect.promise<{
sessionID: SessionID
requestID: PermissionID
reply: Permission.Reply
}>(
() =>
new Promise((res) => {
resolve = res
}),
)
const fiber = yield* ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => {
resolve(event.properties)
})
try {
yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" })
yield* Fiber.join(fiber)
expect(yield* seen).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
reply: "once",
})
} finally {
unsub()
}
}),
),
)
it.live("permission requests stay isolated by directory", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped({ git: true })
const two = yield* tmpdirScoped({ git: true })
const runOne = withProvided(one)
const runTwo = withProvided(two)
const a = yield* ask({
id: PermissionID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(runOne, Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
metadata: {},
always: [],
ruleset: [],
}).pipe(runTwo, Effect.forkScoped)
const onePending = yield* waitForPending(1).pipe(runOne)
const twoPending = yield* waitForPending(1).pipe(runTwo)
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
yield* reply({ requestID: onePending[0].id, reply: "reject" }).pipe(runOne)
yield* reply({ requestID: twoPending[0].id, reply: "reject" }).pipe(runTwo)
yield* Fiber.await(a)
yield* Fiber.await(b)
}),
)
it.live("pending permission rejects on instance dispose", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(run, Effect.forkScoped)
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() }))
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
it.live("pending permission rejects on instance reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(run, Effect.forkScoped)
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
yield* Effect.promise(() => Instance.reload({ directory: dir }))
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
it.live("reply - does nothing for unknown requestID", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" })
expect(yield* list()).toHaveLength(0)
}),
),
)
it.live("ask - checks all patterns and stops on first deny", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
}),
),
)
it.live("ask - allows all patterns when all match allow rules", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const result = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
}),
),
)
it.live("ask - should deny even when an earlier pattern is ask", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "echo *", action: "ask" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(yield* list()).toHaveLength(0)
}),
),
)
it.live("ask - abort should clear pending request", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}).pipe(run, Effect.forkScoped)
const pending = yield* waitForPending(1).pipe(run)
expect(pending).toHaveLength(1)
yield* Effect.promise(() => Instance.reload({ directory: dir }))
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)