permission.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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("proves only unconditional configured allows", () =>
  109. Effect.gen(function* () {
  110. const service = yield* Permission.Service
  111. const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
  112. yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
  113. expect(yield* service.allowsAll(input)).toBe(true)
  114. yield* setRules([
  115. { action: "shell", resource: "*", effect: "allow" },
  116. { action: "shell", resource: "rm *", effect: "deny" },
  117. ])
  118. expect(yield* service.allowsAll(input)).toBe(false)
  119. yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
  120. expect(yield* service.allowsAll(input)).toBe(false)
  121. yield* setRules([
  122. { action: "shell", resource: "rm *", effect: "deny" },
  123. { action: "shell", resource: "*", effect: "allow" },
  124. ])
  125. expect(yield* service.allowsAll(input)).toBe(true)
  126. }),
  127. )
  128. it.effect("evaluates against an explicit provider-turn agent", () =>
  129. Effect.gen(function* () {
  130. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  131. const agents = yield* Agent.Service
  132. yield* agents.transform((editor) =>
  133. editor.update(Agent.ID.make("reviewer"), (agent) => {
  134. agent.permissions.push({ action: "read", resource: "*", effect: "deny" })
  135. }),
  136. )
  137. const service = yield* Permission.Service
  138. expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
  139. expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "deny" })
  140. yield* agents.transform((editor) =>
  141. editor.update(Agent.ID.make("reviewer"), (agent) => {
  142. agent.permissions = []
  143. }),
  144. )
  145. expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "ask" })
  146. expect(yield* service.get(Permission.ID.create("per_test"))).not.toHaveProperty("agent")
  147. }),
  148. )
  149. it.effect("allows and denies from explicit rules without asking", () =>
  150. Effect.gen(function* () {
  151. yield* setup([{ action: "read", resource: "*", effect: "allow" }])
  152. const service = yield* Permission.Service
  153. yield* service.assert(assertion())
  154. yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
  155. const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
  156. expect(blocked).toBeInstanceOf(Permission.BlockedError)
  157. expect(yield* service.list()).toEqual([])
  158. }),
  159. )
  160. it.effect("allows managed output reads without granting external directory access", () =>
  161. Effect.gen(function* () {
  162. yield* setup([
  163. { action: "*", resource: "*", effect: "deny" },
  164. { action: "read", resource: "*", effect: "allow" },
  165. ])
  166. const service = yield* Permission.Service
  167. expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
  168. expect(
  169. yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
  170. ).toMatchObject({ effect: "deny" })
  171. }),
  172. )
  173. it.effect("uses build permissions when the Session agent is omitted", () =>
  174. Effect.gen(function* () {
  175. yield* setup()
  176. const { db } = yield* Database.Service
  177. yield* db
  178. .update(SessionTable)
  179. .set({ agent: null })
  180. .where(eq(SessionTable.id, Session.ID.make("ses_test")))
  181. .run()
  182. .pipe(Effect.orDie)
  183. const agents = yield* Agent.Service
  184. yield* agents.transform((editor) =>
  185. editor.update(Agent.ID.make("build"), (agent) => {
  186. agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }]
  187. }),
  188. )
  189. const service = yield* Permission.Service
  190. expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({
  191. id: Permission.ID.create("per_test"),
  192. effect: "allow",
  193. })
  194. expect(yield* service.list()).toEqual([])
  195. }),
  196. )
  197. it.effect("denies omitted-agent permissions when no primary default agent exists", () =>
  198. Effect.gen(function* () {
  199. yield* setup()
  200. const { db } = yield* Database.Service
  201. yield* db
  202. .update(SessionTable)
  203. .set({ agent: null })
  204. .where(eq(SessionTable.id, Session.ID.make("ses_test")))
  205. .run()
  206. .pipe(Effect.orDie)
  207. const agents = yield* Agent.Service
  208. yield* agents.transform((editor) => {
  209. editor.remove(Agent.ID.make("test"))
  210. editor.remove(Agent.ID.make("build"))
  211. })
  212. const service = yield* Permission.Service
  213. expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
  214. expect(yield* service.list()).toEqual([])
  215. }),
  216. )
  217. it.effect("evaluates bash with the normal configured-rule semantics", () =>
  218. Effect.gen(function* () {
  219. yield* setup([{ action: "*", resource: "*", effect: "allow" }])
  220. const service = yield* Permission.Service
  221. const bash = assertion({ action: "bash", resources: ["pwd"] })
  222. expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
  223. yield* setRules([])
  224. expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
  225. expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
  226. }),
  227. )
  228. it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
  229. Effect.gen(function* () {
  230. yield* setup()
  231. const saved = yield* PermissionSaved.Service
  232. yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
  233. const service = yield* Permission.Service
  234. expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
  235. id: Permission.ID.create("per_test"),
  236. effect: "allow",
  237. })
  238. expect(yield* service.list()).toEqual([])
  239. yield* setRules([{ action: "bash", resource: "*", effect: "deny" }])
  240. expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
  241. id: Permission.ID.create("per_test"),
  242. effect: "deny",
  243. })
  244. }),
  245. )
  246. it.effect("resolves an asked permission once", () =>
  247. Effect.gen(function* () {
  248. yield* setup()
  249. const { service, fiber, request } = yield* waitForRequest()
  250. expect(yield* service.list()).toEqual([request])
  251. expect(yield* service.forSession(request.sessionID)).toEqual([request])
  252. expect(yield* service.forSession(Session.ID.make("ses_other"))).toEqual([])
  253. expect(yield* service.get(request.id)).toEqual(request)
  254. yield* service.reply({ requestID: request.id, reply: "once" })
  255. yield* Fiber.join(fiber)
  256. expect(yield* service.list()).toEqual([])
  257. expect(yield* service.get(request.id)).toBeUndefined()
  258. }),
  259. )
  260. it.effect("defects when an asked permission is declined", () =>
  261. Effect.gen(function* () {
  262. yield* setup()
  263. const { service, fiber, request } = yield* waitForRequest()
  264. yield* service.reply({ requestID: request.id, reply: "reject" })
  265. const exit = yield* Fiber.await(fiber)
  266. expect(exit._tag).toBe("Failure")
  267. if (exit._tag === "Failure")
  268. expect(
  269. exit.cause.reasons.some(
  270. (reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
  271. ),
  272. ).toBe(true)
  273. expect(yield* service.list()).toEqual([])
  274. }),
  275. )
  276. it.effect("stores and removes saved resources for a project", () =>
  277. Effect.gen(function* () {
  278. yield* setup()
  279. const service = yield* Permission.Service
  280. const asked = yield* Deferred.make<Permission.Request>()
  281. const bus = yield* Bus.Service
  282. const unsubscribe = yield* bus.listen((event) =>
  283. event.type === Permission.Event.Asked.type
  284. ? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
  285. : Effect.void,
  286. )
  287. yield* Effect.addFinalizer(() => unsubscribe)
  288. const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
  289. const request = yield* Deferred.await(asked)
  290. yield* service.reply({ requestID: request.id, reply: "always" })
  291. yield* Fiber.join(fiber)
  292. const { db } = yield* Database.Service
  293. expect(
  294. yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(),
  295. ).toMatchObject([{ action: "read", resource: "src/*" }])
  296. const saved = yield* PermissionSaved.Service
  297. const id = (yield* saved.list())[0]!.id
  298. expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
  299. yield* service.assert(assertion({ id: Permission.ID.create("per_next"), resources: ["src/next.ts"] }))
  300. yield* saved.remove(id)
  301. expect(yield* saved.list()).toEqual([])
  302. }),
  303. )
  304. })