permission.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. import { describe, expect } from "bun:test"
  2. import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
  3. import { Agent } from "@opencode-ai/core/agent"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Bus } from "@opencode-ai/core/bus"
  8. import { Job } from "@opencode-ai/core/job"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { Permission } from "@opencode-ai/core/permission"
  11. import { PermissionTable } from "@opencode-ai/core/permission/sql"
  12. import { PermissionSaved } from "@opencode-ai/core/permission/saved"
  13. import { Project } from "@opencode-ai/core/project"
  14. import { ProjectTable } from "@opencode-ai/core/project/sql"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { Session } from "@opencode-ai/core/session"
  17. import { SessionTable } from "@opencode-ai/core/session/sql"
  18. import { SessionStore } from "@opencode-ai/core/session/store"
  19. import { eq } from "drizzle-orm"
  20. import { location } from "./fixture/location"
  21. import { testEffect } from "./lib/effect"
  22. const current = Layer.succeed(
  23. Location.Service,
  24. Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
  25. )
  26. const it = testEffect(
  27. AppNodeBuilder.build(
  28. LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
  29. [[Location.node, current]],
  30. ),
  31. )
  32. function setup(rules: Permission.Ruleset = []) {
  33. return Effect.gen(function* () {
  34. const { db } = yield* Database.Service
  35. yield* db
  36. .insert(ProjectTable)
  37. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  38. .onConflictDoNothing()
  39. .run()
  40. .pipe(Effect.orDie)
  41. yield* db
  42. .insert(SessionTable)
  43. .values({
  44. id: Session.ID.make("ses_test"),
  45. project_id: Project.ID.global,
  46. slug: "test",
  47. directory: "/project",
  48. title: "test",
  49. version: "test",
  50. agent: "test",
  51. })
  52. .onConflictDoNothing()
  53. .run()
  54. .pipe(Effect.orDie)
  55. yield* setRules(rules)
  56. })
  57. }
  58. function setRules(rules: Permission.Ruleset) {
  59. return Effect.gen(function* () {
  60. const agents = yield* Agent.Service
  61. yield* agents.transform((editor) =>
  62. editor.update(Agent.ID.make("test"), (agent) => {
  63. agent.permissions = [...rules]
  64. }),
  65. )
  66. })
  67. }
  68. function assertion(input: Partial<Permission.AssertInput> = {}) {
  69. return {
  70. id: Permission.ID.create("per_test"),
  71. sessionID: Session.ID.make("ses_test"),
  72. action: "read",
  73. resources: ["src/index.ts"],
  74. ...input,
  75. } satisfies Permission.AssertInput
  76. }
  77. function waitForRequest() {
  78. return Effect.gen(function* () {
  79. const service = yield* Permission.Service
  80. const bus = yield* Bus.Service
  81. const asked = yield* Deferred.make<Permission.Request>()
  82. const unsubscribe = yield* bus.listen((event) =>
  83. event.type === Permission.Event.Asked.type
  84. ? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
  85. : Effect.void,
  86. )
  87. yield* Effect.addFinalizer(() => unsubscribe)
  88. const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
  89. const request = yield* Deferred.await(asked)
  90. return { service, fiber, request }
  91. })
  92. }
  93. describe("Permission", () => {
  94. it.effect("returns the evaluated effect and only queues prompts", () =>
  95. Effect.gen(function* () {
  96. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  97. const service = yield* Permission.Service
  98. expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
  99. expect(yield* service.list()).toEqual([])
  100. yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
  101. expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
  102. expect(yield* service.list()).toEqual([])
  103. yield* setRules([])
  104. expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
  105. expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
  106. }),
  107. )
  108. it.effect("evaluates against an explicit provider-turn agent", () =>
  109. Effect.gen(function* () {
  110. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  111. const agents = yield* Agent.Service
  112. yield* agents.transform((editor) =>
  113. editor.update(Agent.ID.make("reviewer"), (agent) => {
  114. agent.permissions.push({ action: "read", resource: "*", effect: "deny" })
  115. }),
  116. )
  117. const service = yield* Permission.Service
  118. expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
  119. expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "deny" })
  120. yield* agents.transform((editor) =>
  121. editor.update(Agent.ID.make("reviewer"), (agent) => {
  122. agent.permissions = []
  123. }),
  124. )
  125. expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "ask" })
  126. expect(yield* service.get(Permission.ID.create("per_test"))).not.toHaveProperty("agent")
  127. }),
  128. )
  129. it.effect("allows and denies from explicit rules without asking", () =>
  130. Effect.gen(function* () {
  131. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  132. const service = yield* Permission.Service
  133. yield* service.assert(assertion())
  134. yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
  135. const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
  136. expect(blocked).toBeInstanceOf(Permission.BlockedError)
  137. expect(yield* service.list()).toEqual([])
  138. }),
  139. )
  140. it.effect("allows managed output reads without granting external directory access", () =>
  141. Effect.gen(function* () {
  142. yield* setup([
  143. { action: "*", resource: "*", effect: "deny" },
  144. { action: "read", resource: "*", effect: "allow" },
  145. ])
  146. const service = yield* Permission.Service
  147. expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
  148. expect(
  149. yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
  150. ).toMatchObject({ effect: "deny" })
  151. }),
  152. )
  153. it.effect("uses build permissions when the Session agent is omitted", () =>
  154. Effect.gen(function* () {
  155. yield* setup()
  156. const { db } = yield* Database.Service
  157. yield* db
  158. .update(SessionTable)
  159. .set({ agent: null })
  160. .where(eq(SessionTable.id, Session.ID.make("ses_test")))
  161. .run()
  162. .pipe(Effect.orDie)
  163. const agents = yield* Agent.Service
  164. yield* agents.transform((editor) =>
  165. editor.update(Agent.ID.make("build"), (agent) => {
  166. agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }]
  167. }),
  168. )
  169. const service = yield* Permission.Service
  170. expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({
  171. id: Permission.ID.create("per_test"),
  172. effect: "allow",
  173. })
  174. expect(yield* service.list()).toEqual([])
  175. }),
  176. )
  177. it.effect("denies omitted-agent permissions when no primary default agent exists", () =>
  178. Effect.gen(function* () {
  179. yield* setup()
  180. const { db } = yield* Database.Service
  181. yield* db
  182. .update(SessionTable)
  183. .set({ agent: null })
  184. .where(eq(SessionTable.id, Session.ID.make("ses_test")))
  185. .run()
  186. .pipe(Effect.orDie)
  187. const agents = yield* Agent.Service
  188. yield* agents.transform((editor) => {
  189. editor.remove(Agent.ID.make("test"))
  190. editor.remove(Agent.ID.make("build"))
  191. })
  192. const service = yield* Permission.Service
  193. expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
  194. expect(yield* service.list()).toEqual([])
  195. }),
  196. )
  197. it.effect("evaluates bash with the normal configured-rule semantics", () =>
  198. Effect.gen(function* () {
  199. yield* setup([{ action: "*", resource: "*", effect: "allow" }])
  200. const service = yield* Permission.Service
  201. const bash = assertion({ action: "bash", resources: ["pwd"] })
  202. expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
  203. yield* setRules([])
  204. expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
  205. expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
  206. }),
  207. )
  208. it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
  209. Effect.gen(function* () {
  210. yield* setup()
  211. const saved = yield* PermissionSaved.Service
  212. yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
  213. const service = yield* Permission.Service
  214. expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
  215. id: Permission.ID.create("per_test"),
  216. effect: "allow",
  217. })
  218. expect(yield* service.list()).toEqual([])
  219. yield* setRules([{ action: "bash", resource: "*", effect: "deny" }])
  220. expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
  221. id: Permission.ID.create("per_test"),
  222. effect: "deny",
  223. })
  224. }),
  225. )
  226. it.effect("resolves an asked permission once", () =>
  227. Effect.gen(function* () {
  228. yield* setup()
  229. const { service, fiber, request } = yield* waitForRequest()
  230. expect(yield* service.list()).toEqual([request])
  231. expect(yield* service.forSession(request.sessionID)).toEqual([request])
  232. expect(yield* service.forSession(Session.ID.make("ses_other"))).toEqual([])
  233. expect(yield* service.get(request.id)).toEqual(request)
  234. yield* service.reply({ requestID: request.id, reply: "once" })
  235. yield* Fiber.join(fiber)
  236. expect(yield* service.list()).toEqual([])
  237. expect(yield* service.get(request.id)).toBeUndefined()
  238. }),
  239. )
  240. it.effect("defects when an asked permission is declined", () =>
  241. Effect.gen(function* () {
  242. yield* setup()
  243. const { service, fiber, request } = yield* waitForRequest()
  244. yield* service.reply({ requestID: request.id, reply: "reject" })
  245. const exit = yield* Fiber.await(fiber)
  246. expect(exit._tag).toBe("Failure")
  247. if (exit._tag === "Failure")
  248. expect(
  249. exit.cause.reasons.some(
  250. (reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
  251. ),
  252. ).toBe(true)
  253. expect(yield* service.list()).toEqual([])
  254. }),
  255. )
  256. it.effect("stores and removes saved resources for a project", () =>
  257. Effect.gen(function* () {
  258. yield* setup()
  259. const service = yield* Permission.Service
  260. const asked = yield* Deferred.make<Permission.Request>()
  261. const bus = yield* Bus.Service
  262. const unsubscribe = yield* bus.listen((event) =>
  263. event.type === Permission.Event.Asked.type
  264. ? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
  265. : Effect.void,
  266. )
  267. yield* Effect.addFinalizer(() => unsubscribe)
  268. const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
  269. const request = yield* Deferred.await(asked)
  270. yield* service.reply({ requestID: request.id, reply: "always" })
  271. yield* Fiber.join(fiber)
  272. const { db } = yield* Database.Service
  273. expect(
  274. yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(),
  275. ).toMatchObject([{ action: "read", resource: "src/*" }])
  276. const saved = yield* PermissionSaved.Service
  277. const id = (yield* saved.list())[0]!.id
  278. expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
  279. yield* service.assert(assertion({ id: Permission.ID.create("per_next"), resources: ["src/next.ts"] }))
  280. yield* saved.remove(id)
  281. expect(yield* saved.list()).toEqual([])
  282. }),
  283. )
  284. })