permission.test.ts 12 KB

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