diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts
index e4ea0d6cebd..86cb284693e 120000
--- a/packages/app/src/custom-elements.d.ts
+++ b/packages/app/src/custom-elements.d.ts
@@ -1 +1 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
+///
diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts
index e4ea0d6cebd..86cb284693e 120000
--- a/packages/enterprise/src/custom-elements.d.ts
+++ b/packages/enterprise/src/custom-elements.d.ts
@@ -1 +1 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
+///
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index 98c7ece1052..efc303062f2 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -7,6 +7,8 @@ import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
+import { Agent } from "../../agent/agent"
+import { PermissionNext } from "../../permission/next"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -33,8 +35,31 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
+ validator(
+ "query",
+ z.object({
+ provider: z.string().optional(),
+ model: z.string().optional(),
+ agent: z.string().optional(),
+ sessionID: z.string().optional(),
+ }),
+ ),
async (c) => {
- return c.json(await ToolRegistry.ids())
+ const { provider, model, agent: agentName, sessionID } = c.req.valid("query")
+ if (!provider || !model) {
+ return c.json(await ToolRegistry.ids())
+ }
+
+ const agent = await Agent.get(agentName ?? (await Agent.defaultAgent()))
+ const sessionPermission = sessionID ? ((await Session.get(sessionID)).permission ?? []) : []
+
+ const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }, agent)
+ const disabled = PermissionNext.disabled(
+ tools.map((tool) => tool.id),
+ PermissionNext.merge(agent.permission, sessionPermission),
+ )
+
+ return c.json(tools.filter((tool) => !disabled.has(tool.id)).map((tool) => tool.id))
},
)
.get(
@@ -73,18 +98,30 @@ export const ExperimentalRoutes = lazy(() =>
z.object({
provider: z.string(),
model: z.string(),
+ agent: z.string().optional(),
+ sessionID: z.string().optional(),
}),
),
async (c) => {
- const { provider, model } = c.req.valid("query")
- const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
+ const { provider, model, agent: agentName, sessionID } = c.req.valid("query")
+ const agent = await Agent.get(agentName ?? (await Agent.defaultAgent()))
+ const sessionPermission = sessionID ? ((await Session.get(sessionID)).permission ?? []) : []
+
+ const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }, agent)
+ const disabled = PermissionNext.disabled(
+ tools.map((tool) => tool.id),
+ PermissionNext.merge(agent.permission, sessionPermission),
+ )
+
return c.json(
- tools.map((t) => ({
- id: t.id,
- description: t.description,
- // Handle both Zod schemas and plain JSON schemas
- parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
- })),
+ tools
+ .filter((tool) => !disabled.has(tool.id))
+ .map((t) => ({
+ id: t.id,
+ description: t.description,
+ // Handle both Zod schemas and plain JSON schemas
+ parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
+ })),
)
},
)
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 4e42fb0d2ec..4bf84f284a7 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -32,6 +32,7 @@ export namespace LLM {
sessionID: string
model: Provider.Model
agent: Agent.Info
+ sessionPermission?: PermissionNext.Ruleset
system: string[]
abort: AbortSignal
messages: ModelMessage[]
@@ -255,8 +256,9 @@ export namespace LLM {
})
}
- async function resolveTools(input: Pick) {
- const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
+ async function resolveTools(input: Pick) {
+ const ruleset = PermissionNext.merge(input.agent.permission, input.sessionPermission ?? [])
+ const disabled = PermissionNext.disabled(Object.keys(input.tools), ruleset)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
delete input.tools[tool]
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 655afd2b14d..9cfa2b0fe17 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -659,6 +659,7 @@ export namespace SessionPrompt {
const result = await processor.process({
user: lastUser,
agent,
+ sessionPermission: session.permission,
abort,
sessionID,
system,
diff --git a/packages/opencode/test/server/experimental-tools.test.ts b/packages/opencode/test/server/experimental-tools.test.ts
new file mode 100644
index 00000000000..e42f182bfb9
--- /dev/null
+++ b/packages/opencode/test/server/experimental-tools.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, test } from "bun:test"
+import { Hono } from "hono"
+import { ExperimentalRoutes } from "../../src/server/routes/experimental"
+import { Instance } from "../../src/project/instance"
+import { Session } from "../../src/session"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+function route() {
+ return new Hono().route("/experimental", ExperimentalRoutes())
+}
+
+async function listTools(query: Record) {
+ const qs = new URLSearchParams(query)
+ const res = await route().request(`/experimental/tool?${qs.toString()}`)
+ expect(res.status).toBe(200)
+ return (await res.json()) as Array<{ id: string }>
+}
+
+async function listToolIDs(query: Record) {
+ const qs = new URLSearchParams(query)
+ const suffix = qs.toString()
+ const res = await route().request(`/experimental/tool/ids${suffix ? `?${suffix}` : ""}`)
+ expect(res.status).toBe(200)
+ return (await res.json()) as string[]
+}
+
+describe("ExperimentalRoutes /experimental/tool", () => {
+ test("applies agent permissions when listing visible tools", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const buildTools = await listTools({ provider: "openai", model: "qwen-plus", agent: "build" })
+ const exploreTools = await listTools({ provider: "openai", model: "qwen-plus", agent: "explore" })
+
+ expect(buildTools.some((tool) => tool.id === "edit")).toBe(true)
+ expect(exploreTools.some((tool) => tool.id === "edit")).toBe(false)
+ },
+ })
+ })
+
+ test("applies session permissions when sessionID is provided", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await Session.create({})
+ await Session.setPermission({
+ sessionID: session.id,
+ permission: [{ permission: "bash", pattern: "*", action: "deny" }],
+ })
+
+ const withSession = await listTools({
+ provider: "openai",
+ model: "qwen-plus",
+ agent: "build",
+ sessionID: session.id,
+ })
+
+ expect(withSession.some((tool) => tool.id === "bash")).toBe(false)
+ },
+ })
+ })
+})
+
+describe("ExperimentalRoutes /experimental/tool/ids", () => {
+ test("applies agent permissions when listing visible tool IDs", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const buildToolIDs = await listToolIDs({ provider: "openai", model: "qwen-plus", agent: "build" })
+ const exploreToolIDs = await listToolIDs({ provider: "openai", model: "qwen-plus", agent: "explore" })
+
+ expect(buildToolIDs.includes("edit")).toBe(true)
+ expect(exploreToolIDs.includes("edit")).toBe(false)
+ },
+ })
+ })
+
+ test("applies session permissions to tool IDs when sessionID is provided", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await Session.create({})
+ await Session.setPermission({
+ sessionID: session.id,
+ permission: [{ permission: "bash", pattern: "*", action: "deny" }],
+ })
+
+ const withSession = await listToolIDs({
+ provider: "openai",
+ model: "qwen-plus",
+ agent: "build",
+ sessionID: session.id,
+ })
+
+ expect(withSession.includes("bash")).toBe(false)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts
index a89a00ebc05..bbf21875bec 100644
--- a/packages/opencode/test/session/llm.test.ts
+++ b/packages/opencode/test/session/llm.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
-import type { ModelMessage } from "ai"
+import { jsonSchema, type ModelMessage, tool } from "ai"
import { LLM } from "../../src/session/llm"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
@@ -221,6 +221,125 @@ function createEventResponse(chunks: unknown[], includeDone = false) {
}
describe("session.llm.stream", () => {
+ test("applies session permissions before exposing tools to the model", async () => {
+ const server = state.server
+ if (!server) {
+ throw new Error("Server not initialized")
+ }
+
+ const providerID = "alibaba"
+ const modelID = "qwen-plus"
+ const fixture = await loadFixture(providerID, modelID)
+
+ const allowedRequest = waitRequest(
+ "/chat/completions",
+ new Response(createChatStream("allowed"), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ }),
+ )
+
+ const deniedRequest = waitRequest(
+ "/chat/completions",
+ new Response(createChatStream("denied"), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ }),
+ )
+
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ enabled_providers: [providerID],
+ provider: {
+ [providerID]: {
+ options: {
+ apiKey: "test-key",
+ baseURL: `${server.url.origin}/v1`,
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const resolved = await Provider.getModel(providerID, fixture.model.id)
+ const sessionID = "session-test-session-permissions"
+ const agent = {
+ name: "test",
+ mode: "primary",
+ options: {},
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
+ } satisfies Agent.Info
+
+ const user = {
+ id: "user-permission-1",
+ sessionID,
+ role: "user",
+ time: { created: Date.now() },
+ agent: agent.name,
+ model: { providerID, modelID: resolved.id },
+ } satisfies MessageV2.User
+
+ const bashTool = tool({
+ description: "Run shell commands",
+ inputSchema: jsonSchema({
+ type: "object",
+ properties: {
+ command: { type: "string" },
+ },
+ required: ["command"],
+ additionalProperties: false,
+ }),
+ execute: async () => ({ output: "", title: "", metadata: {} }),
+ })
+
+ const allowedStream = await LLM.stream({
+ user,
+ sessionID,
+ model: resolved,
+ agent,
+ system: ["You are a helpful assistant."],
+ abort: new AbortController().signal,
+ messages: [{ role: "user", content: "Hello" }],
+ tools: { bash: bashTool },
+ })
+ for await (const _ of allowedStream.fullStream) {
+ }
+
+ const allowedCapture = await allowedRequest
+ const allowedTools = allowedCapture.body.tools as Array<{ function?: { name?: string } }> | undefined
+ expect(Array.isArray(allowedTools)).toBe(true)
+ expect(allowedTools?.some((item) => item.function?.name === "bash")).toBe(true)
+
+ const deniedStream = await LLM.stream({
+ user: { ...user, id: "user-permission-2" },
+ sessionID,
+ model: resolved,
+ agent,
+ sessionPermission: [{ permission: "bash", pattern: "*", action: "deny" }],
+ system: ["You are a helpful assistant."],
+ abort: new AbortController().signal,
+ messages: [{ role: "user", content: "Hello again" }],
+ tools: { bash: bashTool },
+ })
+ for await (const _ of deniedStream.fullStream) {
+ }
+
+ const deniedCapture = await deniedRequest
+ const deniedTools = deniedCapture.body.tools as Array<{ function?: { name?: string } }> | undefined
+ expect(deniedTools?.some((item) => item.function?.name === "bash") ?? false).toBe(false)
+ },
+ })
+ })
+
test("sends temperature, tokens, and reasoning options for openai-compatible models", async () => {
const server = state.server
if (!server) {
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 8eefe5bfe98..eb2a891a8af 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1953,6 +1953,10 @@ export type ToolIdsData = {
path?: never
query?: {
directory?: string
+ provider?: string
+ model?: string
+ agent?: string
+ sessionID?: string
}
url: "/experimental/tool/ids"
}
@@ -1982,6 +1986,8 @@ export type ToolListData = {
directory?: string
provider: string
model: string
+ agent?: string
+ sessionID?: string
}
url: "/experimental/tool"
}
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 2bb2edcd175..8e75753213e 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -837,6 +837,10 @@ export class Tool extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
+ provider?: string
+ model?: string
+ agent?: string
+ sessionID?: string
},
options?: Options,
) {
@@ -847,6 +851,10 @@ export class Tool extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "query", key: "provider" },
+ { in: "query", key: "model" },
+ { in: "query", key: "agent" },
+ { in: "query", key: "sessionID" },
],
},
],
@@ -869,6 +877,8 @@ export class Tool extends HeyApiClient {
workspace?: string
provider: string
model: string
+ agent?: string
+ sessionID?: string
},
options?: Options,
) {
@@ -881,6 +891,8 @@ export class Tool extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
+ { in: "query", key: "agent" },
+ { in: "query", key: "sessionID" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index a47b18db219..c162193c7c7 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -2415,6 +2415,10 @@ export type ToolIdsData = {
query?: {
directory?: string
workspace?: string
+ provider?: string
+ model?: string
+ agent?: string
+ sessionID?: string
}
url: "/experimental/tool/ids"
}
@@ -2445,6 +2449,8 @@ export type ToolListData = {
workspace?: string
provider: string
model: string
+ agent?: string
+ sessionID?: string
}
url: "/experimental/tool"
}