session-runner-tool-registry.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { describe, expect } from "bun:test"
  2. import { Tool, ToolFailure } from "@opencode-ai/llm"
  3. import { PermissionV2 } from "@opencode-ai/core/permission"
  4. import { SessionV2 } from "@opencode-ai/core/session"
  5. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  6. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  7. import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
  8. import { Effect, Exit, Layer, Schema, Scope } from "effect"
  9. import { testEffect } from "./lib/effect"
  10. const assertions: PermissionV2.AssertInput[] = []
  11. let denyAction: string | undefined
  12. const permission = Layer.succeed(
  13. PermissionV2.Service,
  14. PermissionV2.Service.of({
  15. assert: (input) =>
  16. Effect.sync(() => assertions.push(input)).pipe(
  17. Effect.andThen(
  18. input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
  19. ),
  20. ),
  21. ask: () => Effect.die("unused"),
  22. reply: () => Effect.die("unused"),
  23. get: () => Effect.die("unused"),
  24. forSession: () => Effect.die("unused"),
  25. list: () => Effect.die("unused"),
  26. }),
  27. )
  28. const bounds: ToolOutputStore.BoundInput[] = []
  29. const outputStore = Layer.mock(ToolOutputStore.Service, {
  30. bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
  31. })
  32. const registry = ToolRegistry.layer.pipe(
  33. Layer.provide(permission),
  34. Layer.provide(ApplicationTools.layer),
  35. Layer.provide(outputStore),
  36. )
  37. const it = testEffect(Layer.mergeAll(permission, registry))
  38. const echo = Tool.make({
  39. description: "Echo text",
  40. parameters: Schema.Struct({ text: Schema.String }),
  41. success: Schema.Struct({ text: Schema.String }),
  42. execute: ({ text }) => Effect.succeed({ text }),
  43. })
  44. describe("ToolRegistry", () => {
  45. it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
  46. Effect.gen(function* () {
  47. const registry = yield* ToolRegistry.Service
  48. const transform = yield* registry.transform()
  49. const sessionID = SessionV2.ID.make("ses_registry_filter")
  50. yield* transform((editor) => {
  51. editor.set("question", { tool: echo })
  52. editor.set("bash", { tool: echo })
  53. editor.set("edit", { tool: echo })
  54. editor.set("write", { tool: echo })
  55. editor.set("apply_patch", { tool: echo })
  56. })
  57. const names = (rules: PermissionV2.Ruleset) =>
  58. registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
  59. expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
  60. "bash",
  61. "edit",
  62. "write",
  63. "apply_patch",
  64. ])
  65. expect(
  66. yield* names([
  67. { action: "*", resource: "*", effect: "deny" },
  68. { action: "question", resource: "private", effect: "allow" },
  69. ]),
  70. ).toEqual(["question"])
  71. expect(
  72. yield* names([
  73. { action: "question", resource: "private", effect: "allow" },
  74. { action: "*", resource: "*", effect: "deny" },
  75. ]),
  76. ).toEqual([])
  77. expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question")
  78. expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
  79. expect(
  80. yield* names([
  81. { action: "edit", resource: "*", effect: "deny" },
  82. { action: "edit", resource: "*.md", effect: "ask" },
  83. ]),
  84. ).toEqual(["question", "bash", "edit", "write", "apply_patch"])
  85. expect(
  86. yield* names([
  87. { action: "edit", resource: "*.md", effect: "allow" },
  88. { action: "edit", resource: "*", effect: "deny" },
  89. ]),
  90. ).toEqual(["question", "bash"])
  91. }),
  92. )
  93. it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
  94. Effect.gen(function* () {
  95. const registry = yield* ToolRegistry.Service
  96. const transform = yield* registry.transform()
  97. const sessionID = SessionV2.ID.make("ses_registry_stale")
  98. let executed = false
  99. yield* transform((editor) =>
  100. editor.set("question", {
  101. tool: echo,
  102. permission: { action: "question", resource: "*" },
  103. authorize: ({ assertPermission }) =>
  104. assertPermission({ action: "question", resources: ["actual"] }).pipe(
  105. Effect.mapError(() => new ToolFailure({ message: "Denied" })),
  106. ),
  107. execute: () =>
  108. Effect.sync(() => {
  109. executed = true
  110. return { text: "unexpected" }
  111. }),
  112. }),
  113. )
  114. expect(
  115. (yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
  116. ).toEqual([])
  117. expect(
  118. yield* registry.settle({
  119. sessionID,
  120. call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
  121. }),
  122. ).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
  123. expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
  124. expect(executed).toBe(true)
  125. }),
  126. )
  127. it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
  128. Effect.gen(function* () {
  129. const registry = yield* ToolRegistry.Service
  130. const scope = yield* Scope.make()
  131. const transform = yield* registry.transform().pipe(Scope.provide(scope))
  132. yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
  133. expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
  134. yield* Scope.close(scope, Exit.void)
  135. expect(yield* registry.definitions()).toEqual([])
  136. }),
  137. )
  138. it.effect("returns an error result for an unknown tool", () =>
  139. Effect.gen(function* () {
  140. const registry = yield* ToolRegistry.Service
  141. expect(
  142. yield* registry.execute({
  143. sessionID: SessionV2.ID.make("ses_registry_test"),
  144. call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
  145. }),
  146. ).toEqual({ type: "error", value: "Unknown tool: missing" })
  147. }),
  148. )
  149. it.effect("does not execute a tool when authorization fails", () =>
  150. Effect.gen(function* () {
  151. const registry = yield* ToolRegistry.Service
  152. let executed = false
  153. const transform = yield* registry.transform()
  154. yield* transform((editor) =>
  155. editor.set("denied", {
  156. authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
  157. tool: Tool.make({
  158. description: "Denied tool",
  159. parameters: Schema.Struct({}),
  160. success: Schema.Struct({ ok: Schema.Boolean }),
  161. execute: () =>
  162. Effect.sync(() => {
  163. executed = true
  164. return { ok: true }
  165. }),
  166. }),
  167. }),
  168. )
  169. expect(
  170. yield* registry.execute({
  171. sessionID: SessionV2.ID.make("ses_registry_test"),
  172. call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
  173. }),
  174. ).toEqual({ type: "error", value: "Denied" })
  175. expect(executed).toBe(false)
  176. }),
  177. )
  178. it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
  179. Effect.gen(function* () {
  180. assertions.length = 0
  181. denyAction = undefined
  182. const registry = yield* ToolRegistry.Service
  183. const transform = yield* registry.transform()
  184. const sessionID = SessionV2.ID.make("ses_registry_context")
  185. yield* transform((editor) =>
  186. editor.set("context", {
  187. tool: Tool.make({
  188. description: "Context tool",
  189. parameters: Schema.Struct({}),
  190. success: Schema.Struct({ ok: Schema.Boolean }),
  191. }),
  192. execute: ({ assertPermission, call, source }) =>
  193. assertPermission({
  194. action: "inspect",
  195. resources: [call.id],
  196. save: ["*"],
  197. metadata: { tool: call.name },
  198. }).pipe(
  199. Effect.as({ ok: source === undefined }),
  200. Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
  201. ),
  202. }),
  203. )
  204. expect(
  205. yield* registry.execute({
  206. sessionID,
  207. call: { type: "tool-call", id: "call-context", name: "context", input: {} },
  208. }),
  209. ).toEqual({ type: "json", value: { ok: true } })
  210. expect(assertions).toEqual([
  211. {
  212. sessionID,
  213. action: "inspect",
  214. resources: ["call-context"],
  215. save: ["*"],
  216. metadata: { tool: "context" },
  217. },
  218. ])
  219. expect(assertions[0]).not.toHaveProperty("source")
  220. }),
  221. )
  222. it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
  223. Effect.gen(function* () {
  224. assertions.length = 0
  225. denyAction = "execute"
  226. let executed = false
  227. const registry = yield* ToolRegistry.Service
  228. const transform = yield* registry.transform()
  229. yield* transform((editor) =>
  230. editor.set("ordered", {
  231. tool: Tool.make({
  232. description: "Ordered policy tool",
  233. parameters: Schema.Struct({}),
  234. success: Schema.Struct({ ok: Schema.Boolean }),
  235. }),
  236. execute: ({ assertPermission }) =>
  237. Effect.gen(function* () {
  238. yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
  239. yield* assertPermission({ action: "execute", resources: ["pwd"] })
  240. executed = true
  241. return { ok: true }
  242. }).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
  243. }),
  244. )
  245. expect(
  246. yield* registry.execute({
  247. sessionID: SessionV2.ID.make("ses_registry_context"),
  248. call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
  249. }),
  250. ).toEqual({ type: "error", value: "Denied" })
  251. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
  252. expect(executed).toBe(false)
  253. denyAction = undefined
  254. }),
  255. )
  256. it.effect("settles encoded structured output with canonical projected content", () =>
  257. Effect.gen(function* () {
  258. bounds.length = 0
  259. const registry = yield* ToolRegistry.Service
  260. const transform = yield* registry.transform()
  261. yield* transform((editor) =>
  262. editor.set("projected", {
  263. tool: Tool.make({
  264. description: "Projected tool",
  265. parameters: Schema.Struct({ prefix: Schema.String }),
  266. success: Schema.Struct({ count: Schema.NumberFromString }),
  267. execute: () => Effect.succeed({ count: 2 }),
  268. toModelOutput: ({ callID, parameters, output }) => [
  269. { type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
  270. ],
  271. }),
  272. }),
  273. )
  274. expect(
  275. yield* registry.settle({
  276. sessionID: SessionV2.ID.make("ses_registry_test"),
  277. call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
  278. }),
  279. ).toMatchObject({
  280. result: { type: "text", value: "call-projected:count:2" },
  281. output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
  282. })
  283. expect(bounds).toEqual([
  284. {
  285. sessionID: SessionV2.ID.make("ses_registry_test"),
  286. toolCallID: "call-projected",
  287. output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
  288. },
  289. ])
  290. }),
  291. )
  292. })