opencode/packages/core/test/permission.test.ts
Kit Langton 36ac35a7c8
refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00

816 lines
31 KiB
TypeScript

import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import type { PermissionEvaluation } from "@opencode-ai/plugin/effect/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { ShellParse } from "@opencode-ai/core/shell/parse"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[Location.node.replace(current)],
),
)
function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_test")) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* setRules(rules)
})
}
function setRules(rules: Permission.Ruleset) {
return Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("test"), (agent) => {
agent.permissions = [...rules]
}),
)
})
}
function assertion(input: Partial<Permission.AssertInput> = {}) {
return {
id: Permission.ID.create("per_test"),
sessionID: Session.ID.make("ses_test"),
action: "read",
resources: ["src/index.ts"],
...input,
} satisfies Permission.AssertInput
}
function waitForRequest(input: Partial<Permission.AssertInput> = {}) {
return Effect.gen(function* () {
const value = assertion(input)
const service = yield* Permission.Service
const bus = yield* Bus.Service
const asked = yield* Deferred.make<Permission.Request>()
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Permission.Event.Asked.type) return Effect.void
const request = event.data as Permission.Request
return request.id === value.id ? Deferred.succeed(asked, request).pipe(Effect.asVoid) : Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(value).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
return { service, fiber, request }
})
}
describe("Permission", () => {
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
yield* setRules([])
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("evaluates against an explicit agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "read", resource: "*", effect: "deny" })
}),
)
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "deny" })
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
)
expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).not.toHaveProperty("agent")
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* Permission.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
expect(blocked).toBeInstanceOf(Permission.BlockedError)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("lets plugins review allow and ask decisions without overriding configured denies", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push(event.effect)
event.effect = event.action === "write" ? "deny" : "allow"
event.message = "Reviewed by policy"
}),
)
const service = yield* Permission.Service
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
yield* setRules([])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_ask") }))).toMatchObject({ effect: "allow" })
expect(yield* service.list()).toEqual([])
const blocked = yield* service
.assert(assertion({ id: Permission.ID.create("per_write"), action: "write" }))
.pipe(Effect.flip)
expect(blocked).toBeInstanceOf(Permission.BlockedError)
expect(blocked.message).toBe("Reviewed by policy")
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_deny") }))).toMatchObject({ effect: "deny" })
expect(seen).toEqual(["allow", "ask", "ask"])
}),
)
it.effect("publishes the reviewer message when a plugin escalates to ask", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
event.effect = "ask"
event.message = "Confirm production access"
}),
)
const service = yield* Permission.Service
const result = yield* service.ask(assertion())
expect(result.effect).toBe("ask")
expect(yield* service.get(result.id)).toMatchObject({ message: "Confirm production access" })
}),
)
it.effect("allows cancellation while a permission reviewer is running", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
const started = yield* Deferred.make<void>()
yield* hooks.register("permission", "evaluate", () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
)
const service = yield* Permission.Service
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
expect(
yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("uses build permissions when the Session agent is omitted", () =>
Effect.gen(function* () {
yield* setup()
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ agent: null })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("build"), (agent) => {
agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }]
}),
)
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({
id: Permission.ID.create("per_test"),
effect: "allow",
})
expect(yield* service.list()).toEqual([])
}),
)
it.effect("denies omitted-agent permissions when no primary default agent exists", () =>
Effect.gen(function* () {
yield* setup()
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ agent: null })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.remove(Agent.ID.make("test"))
editor.remove(Agent.ID.make("build"))
})
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
}),
)
it.effect("evaluates bash with the normal configured-rule semantics", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const service = yield* Permission.Service
const bash = assertion({ action: "bash", resources: ["pwd"] })
expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
yield* setRules([])
expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
id: Permission.ID.create("per_test"),
effect: "allow",
})
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
id: Permission.ID.create("per_test"),
effect: "deny",
})
}),
)
it.effect("resolves an asked permission once", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
expect(yield* service.list()).toEqual([request])
expect(yield* service.forSession(request.sessionID)).toEqual([request])
expect(yield* service.forSession(Session.ID.make("ses_other"))).toEqual([])
expect(yield* service.get(request.id)).toEqual(request)
yield* service.reply({ requestID: request.id, reply: "once" })
yield* Fiber.join(fiber)
expect(yield* service.list()).toEqual([])
expect(yield* service.get(request.id)).toBeUndefined()
}),
)
it.effect("defects when an asked permission is declined", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
yield* service.reply({ requestID: request.id, reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure")
expect(
exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const asked = yield* Deferred.make<Permission.Request>()
const bus = yield* Bus.Service
const unsubscribe = yield* bus.listen((event) =>
event.type === Permission.Event.Asked.type
? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
yield* service.reply({ requestID: request.id, reply: "always" })
yield* Fiber.join(fiber)
const { db } = yield* Database.Service
expect(
yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(),
).toMatchObject([{ action: "read", resource: "src/*" }])
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
yield* service.assert(assertion({ id: Permission.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
}),
)
for (const effect of ["ask", "deny", "allow"] as const) {
it.effect(`reevaluates pending requests with hooks after always: ${effect}`, () =>
Effect.gen(function* () {
yield* setup()
yield* setup([], Session.ID.make("ses_other"))
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
)
const context = {
sessionID: Session.ID.make("ses_other"),
agent: Agent.ID.make("reviewer"),
action: "read",
resources: ["src/protected.ts", "src/private.ts"],
metadata: { purpose: "protected" },
source: { type: "tool", messageID: "msg_other", id: "call_other" },
} satisfies Permission.AssertInput
const hooks = yield* PluginHooks.Service
const seen: PermissionEvaluation[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push({ ...event })
if (event.effect === "allow") event.effect = effect
}),
)
const selected = yield* waitForRequest({ save: ["src/*"] })
const other = yield* waitForRequest({ id: Permission.ID.create("per_other"), ...context })
expect(yield* selected.service.list()).toEqual([selected.request, other.request])
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
yield* Fiber.join(selected.fiber)
expect(yield* selected.service.list()).toEqual(effect === "allow" ? [] : [other.request])
expect(seen).toMatchObject([
{ sessionID: selected.request.sessionID, effect: "ask" },
{ ...context, effect: "ask" },
{ ...context, effect: "allow" },
])
if (effect !== "allow") {
expect(other.fiber.pollUnsafe()).toBeUndefined()
yield* other.service.reply({ requestID: other.request.id, reply: "once" })
}
yield* Fiber.join(other.fiber)
expect(yield* selected.service.list()).toEqual([])
}),
)
}
for (const guard of ["configured deny", "missing Session"] as const) {
it.effect(`skips pending auto-approval after always for ${guard}`, () =>
Effect.gen(function* () {
yield* setup()
yield* setup([], Session.ID.make("ses_other"))
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
)
const selected = yield* waitForRequest({ save: ["src/*"] })
const other = yield* waitForRequest({
id: Permission.ID.create("per_other"),
sessionID: Session.ID.make("ses_other"),
agent: Agent.ID.make("reviewer"),
})
if (guard === "configured deny") {
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions = [{ action: "read", resource: "*", effect: "deny" }]
}),
)
}
if (guard === "missing Session") {
const { db } = yield* Database.Service
yield* db.delete(SessionTable).where(eq(SessionTable.id, other.request.sessionID)).run().pipe(Effect.orDie)
}
const hooks = yield* PluginHooks.Service
const seen: PermissionEvaluation[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push({ ...event })
event.effect = "allow"
}),
)
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
yield* Fiber.join(selected.fiber)
expect(yield* selected.service.list()).toEqual([other.request])
expect(other.fiber.pollUnsafe()).toBeUndefined()
expect(seen).toEqual([])
yield* Fiber.interrupt(other.fiber)
expect(yield* selected.service.list()).toEqual([])
}),
)
}
})
describe("shell scanner permission impact", () => {
// Fixed cases require matching outcomes; remaining differences are investigation snapshots, not contracts.
// These service-level cases all have command resources; tool tests cover skipped checks and directories.
// Outcome pairs are [legacy, native].
for (const fixture of [
{
name: "timed command preserves wrapper approvals",
shell: "bash",
command: "time -p git status",
approved: ["time *"],
exact: ["time -p git status"],
denied: "time -p git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "coprocess command preserves wrapper approvals",
shell: "bash",
command: "coproc git status",
approved: ["coproc *"],
exact: ["coproc git status"],
denied: "coproc git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "declarations and unset",
shell: "bash",
command: "export X=value; unset X; git status",
approved: ["git status *"],
exact: ["git status"],
denied: "export *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "export with an approved command substitution",
shell: "bash",
command: "export VERSION=$(git describe --tags); npm run build",
approved: ["git describe *", "npm run build *"],
exact: ["git describe --tags", "npm run build"],
denied: "export *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "export retains checks on the command substitution",
shell: "bash",
command: "export VERSION=$(git describe --tags); npm run build",
approved: ["git describe *", "npm run build *"],
exact: ["git describe --tags", "npm run build"],
denied: "git describe *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "redirect after a conditional list",
shell: "bash",
command: "printf ok && git status > output",
approved: ["printf *", "git status *"],
exact: ["printf ok", "git status"],
denied: "git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "redirect after a pipeline",
shell: "bash",
command: "printf ok | cat < input > output",
approved: ["printf *", "cat *"],
exact: ["printf ok", "cat"],
denied: "cat",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "assignment redirect followed by a command",
shell: "bash",
command: "FOO=bar > output; printf done",
approved: ["printf *"],
exact: ["printf done"],
denied: "FOO=bar > output; printf done",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
},
{
name: "assignment redirect with an approved command substitution",
shell: "bash",
command: "VERSION=$(git describe --tags) > build/version.txt",
approved: ["git describe *"],
exact: ["git describe --tags"],
denied: "VERSION=$(git describe --tags) > build/version.txt",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
},
{
name: "substitution in a saved prefix",
shell: "bash",
command: "git $(printf diff) --stat",
approved: ["git *", "printf *"],
exact: ["git $(printf diff) --stat", "printf diff"],
denied: "git $(printf diff) --stat",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "standalone PowerShell scriptblock caller",
shell: "pwsh",
command: "ForEach-Object { Write-Output value }",
approved: ["Write-Output *"],
exact: ["Write-Output value"],
denied: "ForEach-Object *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "tab-separated PowerShell command",
shell: "pwsh",
command: "git\tstatus; Write-Output done",
approved: ["git status *", "Write-Output *"],
exact: ["git status", "Write-Output done"],
denied: "git\tstatus",
savedEffect: ["allow", "ask"],
exactEffect: ["allow", "ask"],
deniedEffect: ["allow", "deny"],
},
{
name: "PowerShell equals-joined argument",
shell: "pwsh",
command: "git --work-tree=src status",
approved: ["git --work-tree *"],
exact: ["git --work-tree"],
denied: "git --work-tree",
savedEffect: ["allow", "ask"],
exactEffect: ["allow", "ask"],
deniedEffect: ["deny", "allow"],
},
] as const) {
for (const scenario of [
{ name: "no approval", saved: [], rules: [], expected: ["ask", "ask"] },
{ name: "saved wildcard", saved: ["*"], rules: [], expected: ["allow", "allow"] },
{ name: "saved command approvals", saved: fixture.approved, rules: [], expected: fixture.savedEffect },
{
name: "exact configured approvals",
saved: [],
rules: fixture.exact.map((resource): Permission.Rule => ({ action: "shell", resource, effect: "allow" })),
expected: fixture.exactEffect,
},
{
name: "exact saved approvals",
saved: fixture.exact,
rules: [],
expected: fixture.exactEffect,
},
{
name: "configured deny despite saved wildcard",
saved: ["*"],
rules: [{ action: "shell", resource: fixture.denied, effect: "deny" }] satisfies Permission.Ruleset,
expected: fixture.deniedEffect,
},
] as const) {
it.live(`${fixture.name}: ${scenario.name}`, () =>
Effect.gen(function* () {
yield* setup(scenario.rules)
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: scenario.saved })
const service = yield* Permission.Service
for (const [index, portable] of [false, true].entries()) {
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
expect(parsed.commands.length).toBeGreaterThan(0)
expect(parsed.directories).toEqual([])
const result = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(result.effect, portable ? "native" : "legacy").toBe(scenario.expected[index])
const pending = yield* service.list()
expect(pending).toHaveLength(result.effect === "ask" ? 1 : 0)
if (result.effect !== "ask") continue
expect(pending[0]?.resources).toEqual(parsed.commands.map((command) => command.resource))
expect(pending[0]?.save).toEqual(parsed.commands.map((command) => command.save))
yield* service.reply({ requestID: result.id, reply: "once" })
expect(yield* service.list()).toEqual([])
}
}),
)
}
}
// Grant/repeat rows select the granting parser; repeat columns select the parser used afterwards.
for (const fixture of [
{
name: "numeric npm script prefix",
shell: "bash",
command: "npm run 123",
grants: [["npm run *"], ["npm run 123 *"]],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: "npm run build",
nextEffect: ["allow", "ask"],
},
{
name: "numeric AWS option prefix",
shell: "bash",
command: "aws --cli-read-timeout 60 s3 ls",
grants: [["aws --cli-read-timeout s3 *"], ["aws --cli-read-timeout 60 *"]],
repeat: [
["ask", "ask"],
["allow", "allow"],
],
next: "aws --cli-read-timeout 60 ec2 describe-instances",
nextEffect: ["ask", "allow"],
},
{
name: "substitution prefix",
shell: "bash",
command: "git $(printf diff) --stat",
grants: [
["git --stat *", "printf *"],
["git $(printf diff) *", "printf *"],
],
repeat: [
["ask", "ask"],
["allow", "allow"],
],
next: "git --stat",
nextEffect: ["allow", "ask"],
},
{
name: "redirect prefix",
shell: "bash",
command: "printf ok && git status > output",
grants: [
["printf *", "git status *"],
["printf *", "git status *"],
],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: "git status --short",
nextEffect: ["allow", "allow"],
},
{
name: "assignment redirect prefix",
shell: "bash",
command: "FOO=bar > output; printf done",
grants: [["printf *"], ["printf *"]],
// Identical saved rules cover only the native resource, regardless of which parser saved them.
repeat: [
["ask", "allow"],
["ask", "allow"],
],
next: "printf next",
nextEffect: ["allow", "allow"],
},
{
name: "PowerShell tab prefix",
shell: "pwsh",
command: "git\tstatus; Write-Output done",
grants: [["Write-Output *"], ["git\tstatus *", "Write-Output *"]],
repeat: [
["allow", "ask"],
["allow", "allow"],
],
next: "git status",
nextEffect: ["ask", "ask"],
},
] as const) {
for (const [origin, portable] of [false, true].entries()) {
it.live(`${fixture.name}: always allow from ${portable ? "native" : "legacy"}, then use either parser`, () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const saved = yield* PermissionSaved.Service
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
const first = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(first.effect).toBe("ask")
expect(yield* service.list()).toHaveLength(1)
yield* service.reply({ requestID: first.id, reply: "always" })
expect(yield* service.list()).toEqual([])
expect((yield* saved.list({ projectID: Project.ID.global })).map((rule) => rule.resource).sort()).toEqual(
[...fixture.grants[portable ? 1 : 0]].sort(),
)
for (const [index, target] of [false, true].entries()) {
for (const command of [fixture.command, fixture.next]) {
const parsed = yield* ShellParse.scan(command, fixture.shell, "/project", { portable: target })
const result = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(result.effect, `${target ? "native" : "legacy"}: ${command}`).toBe(
command === fixture.next ? fixture.nextEffect[origin] : fixture.repeat[origin]?.[index],
)
if (result.effect === "ask") yield* service.reply({ requestID: result.id, reply: "once" })
expect(yield* service.list()).toEqual([])
}
}
}),
)
}
}
})