feat(core): add location-based permission service (#30287)

This commit is contained in:
Dax
2026-06-02 01:32:50 +00:00
committed by GitHub
parent acd620f411
commit 9b815bcbd2
65 changed files with 4970 additions and 552 deletions
+50 -50
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { test, expect } from "bun:test"
import os from "os"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
@@ -5,7 +6,6 @@ import { EventV2Bridge } from "../../src/event-v2-bridge"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Database } from "@opencode-ai/core/database/database"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
@@ -261,8 +261,8 @@ test("merge - preserves rule order", () => {
})
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 defaults: PermissionLegacy.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
@@ -270,8 +270,8 @@ test("merge - config permission overrides default 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 defaults: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
@@ -443,8 +443,8 @@ test("evaluate - later wildcard permission can override earlier specific permiss
})
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 config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
@@ -588,7 +588,7 @@ it.instance(
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}),
{ git: true },
)
@@ -655,10 +655,10 @@ it.instance(
() =>
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const seen = yield* Deferred.make<Permission.Request>()
const seen = yield* Deferred.make<PermissionLegacy.Request>()
const unsub = yield* events.listen((event) => {
if (event.type === Permission.Event.Asked.type)
Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request))
Deferred.doneUnsafe(seen, Effect.succeed(event.data as PermissionLegacy.Request))
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
@@ -703,7 +703,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test1"),
id: PermissionLegacy.ID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -713,7 +713,7 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test1"), reply: "once" })
yield* Fiber.join(fiber)
}),
{ git: true },
@@ -724,7 +724,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2"),
id: PermissionLegacy.ID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -734,11 +734,11 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" })
yield* reply({ requestID: PermissionLegacy.ID.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)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@@ -748,7 +748,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2b"),
id: PermissionLegacy.ID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -759,7 +759,7 @@ it.instance(
yield* waitForPending(1)
yield* reply({
requestID: PermissionID.make("per_test2b"),
requestID: PermissionLegacy.ID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
@@ -768,7 +768,7 @@ it.instance(
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err).toBeInstanceOf(PermissionLegacy.CorrectedError)
expect(String(err)).toContain("Use a safer command")
}
}),
@@ -780,7 +780,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test3"),
id: PermissionLegacy.ID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -790,7 +790,7 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test3"), reply: "always" })
yield* Fiber.join(fiber)
const result = yield* ask({
@@ -811,7 +811,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test4a"),
id: PermissionLegacy.ID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -821,7 +821,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test4b"),
id: PermissionLegacy.ID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
patterns: ["foo.ts"],
@@ -831,13 +831,13 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" })
yield* reply({ requestID: PermissionLegacy.ID.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)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@@ -847,7 +847,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test5a"),
id: PermissionLegacy.ID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -857,7 +857,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test5b"),
id: PermissionLegacy.ID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -867,7 +867,7 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test5a"), reply: "always" })
yield* Fiber.join(a)
yield* Fiber.join(b)
@@ -881,7 +881,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test6a"),
id: PermissionLegacy.ID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["ls"],
@@ -891,7 +891,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test6b"),
id: PermissionLegacy.ID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["ls"],
@@ -901,10 +901,10 @@ it.instance(
}).pipe(Effect.forkScoped)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test6a"), reply: "always" })
yield* Fiber.join(a)
expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")])
expect((yield* list()).map((item) => item.id)).toEqual([PermissionLegacy.ID.make("per_test6b")])
yield* rejectAll()
yield* Fiber.await(b)
@@ -917,10 +917,10 @@ it.instance(
() =>
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>()
const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }>()
const fiber = yield* ask({
id: PermissionID.make("per_test7"),
id: PermissionLegacy.ID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -935,13 +935,13 @@ it.instance(
if (event.type === Permission.Event.Replied.type)
Deferred.doneUnsafe(
seen,
Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }),
Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }),
)
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" })
yield* reply({ requestID: PermissionLegacy.ID.make("per_test7"), reply: "once" })
yield* Fiber.join(fiber)
expect(
yield* Deferred.await(seen).pipe(
@@ -952,7 +952,7 @@ it.instance(
),
).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
requestID: PermissionLegacy.ID.make("per_test7"),
reply: "once",
})
}),
@@ -969,7 +969,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide(
{ directory: one },
ask({
id: PermissionID.make("per_dir_a"),
id: PermissionLegacy.ID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
@@ -984,7 +984,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide(
{ directory: two },
ask({
id: PermissionID.make("per_dir_b"),
id: PermissionLegacy.ID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
@@ -1000,8 +1000,8 @@ it.live("permission requests stay isolated by directory", () =>
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"))
expect(onePending[0].id).toBe(PermissionLegacy.ID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionLegacy.ID.make("per_dir_b"))
yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" }))
yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" }))
@@ -1018,7 +1018,7 @@ it.instance(
const test = yield* TestInstance
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_dispose"),
id: PermissionLegacy.ID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
@@ -1033,7 +1033,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@@ -1045,7 +1045,7 @@ it.instance(
const test = yield* TestInstance
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
@@ -1059,7 +1059,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@@ -1068,7 +1068,7 @@ it.instance(
"reply - fails for unknown requestID",
() =>
Effect.gen(function* () {
const exit = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
const exit = yield* reply({ requestID: PermissionLegacy.ID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
@@ -1095,7 +1095,7 @@ it.instance(
],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}),
{ git: true },
)
@@ -1135,7 +1135,7 @@ it.instance(
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
expect(yield* list()).toHaveLength(0)
}),
{ git: true },
@@ -1149,7 +1149,7 @@ it.instance(
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
@@ -1164,7 +1164,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}),
{ git: true },
)