diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 3e8a4cfa3..b995730ad 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -41,9 +41,11 @@ import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; import { getTraceContext } from "./telemetry.js"; +import { ToolSet } from "./toolSet.js"; import type { AutoModeSwitchRequest, AutoModeSwitchResponse, + CopilotClientMode, CopilotClientOptions, CustomAgentConfig, ExitPlanModeRequest, @@ -134,6 +136,38 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] }); } +function toolFilterListToArray(value: string[] | ToolSet | undefined): string[] | undefined { + if (value === undefined) { + return undefined; + } + return value instanceof ToolSet ? value.toArray() : value; +} + +/** + * Catches misuse of `availableTools`/`excludedTools` at the SDK boundary so + * users get an actionable error rather than a silently-empty filter. + * + * The runtime treats a bare `"*"` as a literal name match for a tool whose + * name is the single character `*`, which the runtime's charset guard would + * reject at registration — so the filter effectively matches nothing. We + * surface that here as an error pointing the developer at the source-qualified + * forms produced by {@link ToolSet}. + */ +function validateToolFilterList(field: string, list: string[] | undefined): void { + if (!list) { + return; + } + for (const entry of list) { + if (entry === "*") { + throw new Error( + `Invalid ${field} entry '*': there is no bare wildcard. ` + + "Use one or more of `new ToolSet().addBuiltIn('*')`, `.addMcp('*')`, " + + "or `.addCustom('*')` to target a specific source." + ); + } + } +} + function isCanvasProviderRequestParams(params: unknown): params is CanvasProviderRequestParams { if (!params || typeof params !== "object") { return false; @@ -298,6 +332,7 @@ export class CopilotClient { baseDirectory?: string; sessionIdleTimeoutSeconds: number; enableRemoteSessions: boolean; + mode: CopilotClientMode; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -445,7 +480,29 @@ export class CopilotClient { baseDirectory: options.baseDirectory, sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0, enableRemoteSessions: options.enableRemoteSessions ?? false, + mode: options.mode ?? "copilot-cli", }; + + // Empty mode: validate at construction time that the app supplied a + // per-session persistence location. The runtime is mode-agnostic, so + // without this check it would silently fall back to ~/.copilot, which + // defeats the point of empty mode for multi-tenant scenarios. + if (this.options.mode === "empty") { + const hasPersistence = + this.options.baseDirectory !== undefined || + this.sessionFsConfig !== null || + // External runtimes manage their own persistence layer; the SDK + // can't enforce it from here. + conn.kind === "uri" || + conn.kind === "parent-process"; + if (!hasPersistence) { + throw new Error( + "CopilotClient was created with mode: 'empty' but neither " + + "'baseDirectory' nor 'sessionFs' was set. Empty mode requires " + + "an explicit per-session persistence location; pick one." + ); + } + } } private connectionExtraArgs: string[] = []; @@ -820,6 +877,46 @@ export class CopilotClient { * }); * ``` */ + /** + * Normalizes session-level tool filter options. Converts {@link ToolSet} + * instances to plain string arrays, rejects misuse (bare `"*"`) and the + * missing-availableTools case in `mode = "empty"`. + * + * The SDK always sends `toolFilterPrecedence: "excluded"` so callers can + * compose include + exclude lists naturally (e.g. "everything matching X + * except Y") regardless of mode. Allowlist-precedence is intentionally not + * exposed — it's available on the runtime side as a CLI-only concession to + * legacy behavior, but SDK consumers always get the composable semantics. + * + * @internal + */ + private resolveToolFilterOptions(config: { + availableTools?: string[] | ToolSet; + excludedTools?: string[] | ToolSet; + }): { + availableTools: string[] | undefined; + excludedTools: string[] | undefined; + toolFilterPrecedence: "excluded"; + } { + const availableTools = toolFilterListToArray(config.availableTools); + const excludedTools = toolFilterListToArray(config.excludedTools); + validateToolFilterList("availableTools", availableTools); + validateToolFilterList("excludedTools", excludedTools); + + if (this.options.mode === "empty") { + if (availableTools === undefined) { + throw new Error( + "CopilotClient is in mode: 'empty' but the session config did not " + + "specify 'availableTools'. Empty mode requires every session to " + + "explicitly opt into the tools it wants — e.g. " + + "`new ToolSet().addBuiltIn(BuiltInTools.Isolated)`." + ); + } + } + + return { availableTools, excludedTools, toolFilterPrecedence: "excluded" }; + } + async createSession(config: SessionConfig): Promise { if (!this.connection) { await this.start(); @@ -869,6 +966,8 @@ export class CopilotClient { this.sessions.set(sessionId, session); this.setupSessionFs(session, config); + const toolFilterOptions = this.resolveToolFilterOptions(config); + try { const response = await this.connection!.sendRequest("session.create", { ...(await getTraceContext(this.onGetTraceContext)), @@ -892,8 +991,9 @@ export class CopilotClient { description: cmd.description, })), systemMessage: wireSystemMessage, - availableTools: config.availableTools, - excludedTools: config.excludedTools, + availableTools: toolFilterOptions.availableTools, + excludedTools: toolFilterOptions.excludedTools, + toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, provider: config.provider, enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, @@ -1008,6 +1108,8 @@ export class CopilotClient { this.sessions.set(sessionId, session); this.setupSessionFs(session, config); + const toolFilterOptions = this.resolveToolFilterOptions(config); + try { const response = await this.connection!.sendRequest("session.resume", { ...(await getTraceContext(this.onGetTraceContext)), @@ -1016,8 +1118,9 @@ export class CopilotClient { model: config.model, reasoningEffort: config.reasoningEffort, systemMessage: wireSystemMessage, - availableTools: config.availableTools, - excludedTools: config.excludedTools, + availableTools: toolFilterOptions.availableTools, + excludedTools: toolFilterOptions.excludedTools, + toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, enableSessionTelemetry: config.enableSessionTelemetry, tools: config.tools?.map((tool) => ({ name: tool.name, diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 229fd4ff0..66d8f13d7 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -636,6 +636,18 @@ export type OptionsUpdateEnvValueMode = | "direct" /** Resolve MCP server environment values from host-side references. */ | "indirect"; +/** + * Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "OptionsUpdateToolFilterPrecedence". + */ +/** @experimental */ +export type OptionsUpdateToolFilterPrecedence = + /** If availableTools is set, it is the only constraint that applies (excludedTools is ignored). Preserves CLI / pre-existing client behavior. Default. */ + | "available" + /** A tool is enabled iff it matches the allowlist (or the allowlist is unset) AND it does not match the denylist. Makes 'all except X' expressible by combining the two lists. */ + | "excluded"; /** * The client's response to the pending permission prompt * @@ -7569,6 +7581,7 @@ export interface SessionUpdateOptionsParams { */ logInteractiveShells?: boolean; envValueMode?: OptionsUpdateEnvValueMode; + toolFilterPrecedence?: OptionsUpdateToolFilterPrecedence; /** * Additional directories to search for skills. */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 42498c58f..2ae3740bb 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,6 +10,7 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; +export { BuiltInTools, ToolSet } from "./toolSet.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; export { Canvas, @@ -54,6 +55,7 @@ export type { AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, + CopilotClientMode, CopilotClientOptions, StdioRuntimeConnection, TcpRuntimeConnection, diff --git a/nodejs/src/toolSet.ts b/nodejs/src/toolSet.ts new file mode 100644 index 000000000..559e9234e --- /dev/null +++ b/nodejs/src/toolSet.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Builder for the {@link SessionConfigBase.availableTools} list using + * source-qualified filter patterns (`builtin:*`, `mcp:`, `custom:*`, etc.). + * + * See plan: client-level Mode = "empty" with explicit tool selection. + */ + +/** + * Tool name character set enforced by the runtime at every registration + * boundary. Mirrors the runtime's `VALID_TOOL_NAME_REGEX`. Used to validate + * names passed to the `ToolSet` builder so misuse is caught at the SDK + * boundary with a better error than the runtime would produce. + */ +const VALID_TOOL_NAME = /^[a-zA-Z0-9_-]+$/; + +function validateName(kind: "builtin" | "mcp" | "custom", name: string): void { + if (name === "*") { + return; + } + if (!VALID_TOOL_NAME.test(name)) { + throw new Error( + `Invalid ${kind} tool name '${name}': tool names must match /^[a-zA-Z0-9_-]+$/ ` + + `or be the wildcard '*'.` + ); + } +} + +/** + * Builder that produces a list of source-qualified tool filter strings for + * {@link SessionConfigBase.availableTools}. + * + * Tools are classified by the runtime at registration time (not from name + * parsing), so `addBuiltIn("foo")` matches only tools the runtime registered + * as built-in, even if an MCP server or custom-agent extension happens to + * register a tool with the same wire name. + * + * @example + * ```typescript + * const tools = new ToolSet() + * .addBuiltIn(BuiltInTools.Isolated) + * .addMcp("*") + * .addCustom("*"); + * + * const session = await client.createSession({ + * availableTools: tools, + * // ... + * }); + * ``` + */ +export class ToolSet { + private readonly items: string[] = []; + + /** + * Adds one or more built-in tool patterns. + * + * @param name A specific built-in tool name (e.g. `"bash"`) or `"*"` to match all + * built-in tools. + */ + addBuiltIn(name: string): ToolSet; + /** + * Adds a list of built-in tool patterns (e.g. {@link BuiltInTools.Isolated}). + */ + addBuiltIn(names: readonly string[]): ToolSet; + addBuiltIn(nameOrNames: string | readonly string[]): ToolSet { + const names = typeof nameOrNames === "string" ? [nameOrNames] : nameOrNames; + for (const name of names) { + validateName("builtin", name); + this.items.push(`builtin:${name}`); + } + return this; + } + + /** + * Adds a custom tool pattern. Matches tools registered via the SDK's + * `tools` option or via custom agents. + * + * @param name A specific custom tool name or `"*"` to match all custom tools. + */ + addCustom(name: string): ToolSet { + validateName("custom", name); + this.items.push(`custom:${name}`); + return this; + } + + /** + * Adds an MCP tool pattern. Matches tools advertised by any configured + * MCP server. + * + * @param toolName The runtime's canonical wire name for the MCP tool + * (e.g. `"github-list_issues"`), or `"*"` to match all MCP tools from + * any server. + */ + addMcp(toolName: string): ToolSet { + validateName("mcp", toolName); + this.items.push(`mcp:${toolName}`); + return this; + } + + /** + * Returns a defensive copy of the accumulated filter strings, suitable for + * passing as {@link SessionConfigBase.availableTools}. + */ + toArray(): string[] { + return [...this.items]; + } +} + +/** + * Curated sets of built-in tool names for common scenarios. Each constant is + * meant to be passed to {@link ToolSet.addBuiltIn}. + */ +export const BuiltInTools = { + /** + * Built-in tools that operate only within the bounds of a single session — + * no host filesystem access outside the session, no cross-session state, + * no host environment access, no network. Safe to enable in `Mode = "empty"` + * scenarios (e.g. multi-tenant servers) without leaking host capabilities. + * + * **Contract:** tools in this set MUST NOT be extended (even behind options + * or args) to read or write state outside the session boundary. Adding + * cross-session or host-state behavior to one of these tools is a + * breaking change that requires removing it from this set. + */ + Isolated: [ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", + ] as readonly string[], +} as const; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 623a4cabd..abf33a8f6 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -13,6 +13,7 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session- import type { CopilotSession } from "./session.js"; import type { RemoteSessionMode } from "./generated/rpc.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; +import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type SessionEvent = GeneratedSessionEvent; export type { SessionFsProvider } from "./sessionFsProvider.js"; @@ -167,6 +168,20 @@ export interface ParentProcessRuntimeConnection { /** @internal */ export type InternalRuntimeConnection = RuntimeConnection | ParentProcessRuntimeConnection; +/** + * Controls SDK defaults for ambient features. + * + * - `"copilot-cli"` (default): Defaults equivalent to Copilot CLI. Useful when + * building a coding agent that shares sessions with Copilot CLI. Do not use + * this mode for server-based multi-user applications — the default coding + * agent has tools and capabilities that operate across sessions and can + * access the host OS environment. + * - `"empty"`: Disables optional features by default. The app must explicitly + * opt into anything it needs. Required for any scenario where CLI-like + * ambient behavior is unsafe (e.g. multi-user servers). + */ +export type CopilotClientMode = "empty" | "copilot-cli"; + export interface CopilotClientOptions { /** * How to connect to the Copilot runtime. When omitted, defaults to @@ -174,6 +189,20 @@ export interface CopilotClientOptions { */ connection?: RuntimeConnection; + /** + * Selects the SDK defaulting strategy. See {@link CopilotClientMode}. + * + * When set to `"empty"`, the SDK validates that the app has supplied the + * required configuration ({@link CopilotClientOptions.baseDirectory} or + * {@link CopilotClientOptions.sessionFs}, plus + * {@link SessionConfigBase.availableTools} on each session) and translates + * session creation requests into runtime options that flip tool filter + * precedence to deny-wins so exclusions are expressible. + * + * @default "copilot-cli" + */ + mode?: CopilotClientMode; + /** * Working directory for the runtime process. * If not set, inherits the current process's working directory. @@ -1523,15 +1552,26 @@ export interface SessionConfigBase { /** * List of tool names to allow. When specified, only these tools will be available. - * Takes precedence over excludedTools. + * + * Supports source-qualified filter patterns (`builtin:*`, `builtin:`, + * `mcp:*`, `mcp:`, `custom:*`, `custom:`) as well as the bare + * name form (exact match across any source). Build this list with + * {@link ToolSet} for type safety and readable intent. + * + * Composes with {@link excludedTools}: a tool is enabled when it matches + * `availableTools` (or `availableTools` is unset) AND it does not match + * `excludedTools`. This lets you express "everything matching X except Y". */ - availableTools?: string[]; + availableTools?: string[] | ToolSet; /** - * List of tool names to disable. All other tools remain available. - * Ignored if availableTools is specified. + * List of tool names to disable. Supports the same pattern syntax as + * {@link availableTools}. + * + * Always takes precedence over {@link availableTools}: a tool listed here + * is disabled even if it also matches `availableTools`. */ - excludedTools?: string[]; + excludedTools?: string[] | ToolSet; /** * Custom provider configuration (BYOK - Bring Your Own Key). diff --git a/nodejs/test/e2e/mode_empty.e2e.test.ts b/nodejs/test/e2e/mode_empty.e2e.test.ts new file mode 100644 index 000000000..df19a9ce7 --- /dev/null +++ b/nodejs/test/e2e/mode_empty.e2e.test.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import fs, { realpathSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { approveAll, BuiltInTools, ToolSet } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { retry } from "./harness/sdkTestHelper.js"; + +/** + * E2E coverage for the Mode = "empty" SDK surface and source-qualified tool + * filter patterns. The runtime is mode-agnostic — these tests verify that the + * SDK's translation reaches the runtime correctly by inspecting: + * - the resulting CapiProxy chat-completion request (the LLM only sees tools + * that the runtime exposed for the session), and + * - end-to-end behavior (asking the agent to use a tool that should or + * shouldn't be enabled). + */ +describe("Mode = empty + ToolSet patterns", async () => { + // Empty mode requires baseDirectory at construction time; the harness + // already creates a per-test home dir but doesn't surface it directly, + // so spin up our own and feed it to the client constructor. + const emptyModeBaseDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-empty-mode-"))); + const { copilotClient: client, openAiEndpoint } = await createSdkTestContext({ + copilotClientOptions: { mode: "empty", baseDirectory: emptyModeBaseDir }, + }); + + async function getToolsExposedToLLM(): Promise { + await retry( + "capture chat completion request", + async () => { + const exchanges = await openAiEndpoint.getExchanges(); + expect(exchanges.length).toBeGreaterThanOrEqual(1); + }, + 1_200 + ); + const exchanges = await openAiEndpoint.getExchanges(); + const tools = exchanges[exchanges.length - 1].request.tools ?? []; + return tools.flatMap((t) => + t.type === "function" && t.function?.name ? [t.function.name] : [] + ); + } + + it("empty mode + Isolated set: shell tool is NOT exposed", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + await session.send({ prompt: "Say hi." }).catch(() => {}); + + const toolNames = await getToolsExposedToLLM(); + // Isolated should not contain shell / fs editing / web fetch / grep. + expect(toolNames).not.toContain("bash"); + expect(toolNames).not.toContain("edit"); + expect(toolNames).not.toContain("grep"); + expect(toolNames).not.toContain("web_fetch"); + // Sanity: at least one of the isolated tools is registered. + const anyIsolated = BuiltInTools.Isolated.some((name) => toolNames.includes(name)); + expect(anyIsolated).toBe(true); + + await session.disconnect(); + }); + + it("empty mode + builtin:* exposes all built-in tools", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("*"), + }); + await session.send({ prompt: "Say hi." }).catch(() => {}); + + const toolNames = await getToolsExposedToLLM(); + // The shell tool name differs by platform (bash vs powershell); + // either way, it's a canonical built-in excluded from Isolated, and + // builtin:* should bring it back. + const shellToolName = process.platform === "win32" ? "powershell" : "bash"; + expect(toolNames).toContain(shellToolName); + + await session.disconnect(); + }); + + it("empty mode + excluded default: excludedTools subtracts from availableTools", async () => { + const shellToolName = process.platform === "win32" ? "powershell" : "bash"; + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("*"), + excludedTools: [`builtin:${shellToolName}`], + }); + await session.send({ prompt: "Say hi." }).catch(() => {}); + + const toolNames = await getToolsExposedToLLM(); + // The platform shell is in builtin:* but explicitly excluded → must not be exposed. + expect(toolNames).not.toContain(shellToolName); + // Other built-ins are still there (proves the subtraction is targeted). + expect(toolNames.length).toBeGreaterThan(0); + + await session.disconnect(); + }); +}); diff --git a/nodejs/test/toolSet.test.ts b/nodejs/test/toolSet.test.ts new file mode 100644 index 000000000..2b9ce93cd --- /dev/null +++ b/nodejs/test/toolSet.test.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { + approveAll, + BuiltInTools, + CopilotClient, + RuntimeConnection, + ToolSet, +} from "../src/index.js"; + +describe("ToolSet builder", () => { + it("emits source-qualified strings", () => { + const items = new ToolSet() + .addBuiltIn("bash") + .addBuiltIn("*") + .addCustom("my_tool") + .addCustom("*") + .addMcp("github-list_issues") + .addMcp("*") + .toArray(); + expect(items).toEqual([ + "builtin:bash", + "builtin:*", + "custom:my_tool", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + ]); + }); + + it("supports array form of addBuiltIn", () => { + const items = new ToolSet().addBuiltIn(["bash", "view"]).toArray(); + expect(items).toEqual(["builtin:bash", "builtin:view"]); + }); + + it("toArray returns a defensive copy", () => { + const set = new ToolSet().addBuiltIn("bash"); + const a = set.toArray(); + a.push("builtin:tampered"); + expect(set.toArray()).toEqual(["builtin:bash"]); + }); + + it("rejects invalid tool names with a clear message", () => { + expect(() => new ToolSet().addBuiltIn("has:colon")).toThrowError(/match/i); + expect(() => new ToolSet().addMcp("has space")).toThrowError(/match/i); + expect(() => new ToolSet().addCustom("")).toThrowError(/match/i); + }); + + it("BuiltInTools.Isolated contains expected within-session-only tools", () => { + // Spot-check: shell / fs / network / cross-session tools must NOT appear. + expect(BuiltInTools.Isolated).not.toContain("bash"); + expect(BuiltInTools.Isolated).not.toContain("edit"); + expect(BuiltInTools.Isolated).not.toContain("grep"); + expect(BuiltInTools.Isolated).not.toContain("web_fetch"); + // And a couple of expected members. + expect(BuiltInTools.Isolated).toContain("ask_user"); + expect(BuiltInTools.Isolated).toContain("task_complete"); + }); +}); + +describe("CopilotClient mode = 'empty'", () => { + it("rejects construction without baseDirectory or sessionFs", () => { + expect( + () => + new CopilotClient({ + mode: "empty", + connection: RuntimeConnection.forStdio(), + }) + ).toThrowError(/empty mode|baseDirectory|sessionFs/i); + }); + + it("accepts construction with baseDirectory", () => { + const c = new CopilotClient({ + mode: "empty", + baseDirectory: "/tmp/copilot-test", + connection: RuntimeConnection.forStdio(), + }); + expect(c).toBeInstanceOf(CopilotClient); + }); + + it("accepts construction with sessionFs", () => { + const c = new CopilotClient({ + mode: "empty", + sessionFs: { + initialCwd: "/tmp/copilot-test-cwd", + sessionStatePath: "/tmp/copilot-test-state", + conventions: "posix", + createProvider: (() => ({}) as any) as any, + }, + connection: RuntimeConnection.forStdio(), + }); + expect(c).toBeInstanceOf(CopilotClient); + }); + + it("rejects createSession without availableTools", async () => { + const client = new CopilotClient({ + mode: "empty", + baseDirectory: "/tmp/copilot-test", + }); + await client.start(); + onTestFinished(() => client.forceStop()); + // Stub the wire so we don't actually need a runtime; the empty-mode + // guard runs before the RPC is issued so this still fails fast. + vi.spyOn((client as any).connection!, "sendRequest").mockResolvedValue({ + sessionId: "irrelevant", + }); + + await expect( + client.createSession({ onPermissionRequest: approveAll }) + ).rejects.toThrowError(/empty.*availableTools/i); + }); +}); + +describe("Tool filter wiring", () => { + async function setupClient(mode?: "empty" | "copilot-cli") { + const client = new CopilotClient({ + mode, + baseDirectory: mode === "empty" ? "/tmp/copilot-test" : undefined, + }); + await client.start(); + onTestFinished(() => client.forceStop()); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create" || method === "session.resume") { + return { sessionId: params.sessionId }; + } + throw new Error(`Unexpected method: ${method}`); + }); + return { client, spy }; + } + + it("converts ToolSet to plain string[] on the wire", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("bash").addMcp("*"), + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.availableTools).toEqual(["builtin:bash", "mcp:*"]); + }); + + it("forwards plain string[] unchanged", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["view", "builtin:bash"], + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.availableTools).toEqual(["view", "builtin:bash"]); + }); + + it("rejects bare '*' in availableTools with actionable error", async () => { + const { client } = await setupClient(); + await expect( + client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["*"], + }) + ).rejects.toThrowError(/bare wildcard|addBuiltIn|addMcp|addCustom/); + }); + + it("rejects bare '*' in excludedTools", async () => { + const { client } = await setupClient(); + await expect( + client.createSession({ + onPermissionRequest: approveAll, + excludedTools: ["*"], + }) + ).rejects.toThrowError(/bare wildcard/); + }); + + it("always sends toolFilterPrecedence: excluded in copilot-cli mode", async () => { + const { client, spy } = await setupClient("copilot-cli"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.toolFilterPrecedence).toBe("excluded"); + }); + + it("always sends toolFilterPrecedence: excluded in empty mode", async () => { + const { client, spy } = await setupClient("empty"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.toolFilterPrecedence).toBe("excluded"); + }); + + it("applies the same filter normalization on session.resume", async () => { + const { client, spy } = await setupClient("empty"); + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("bash"), + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(["view", "task_complete"]), + }); + const payload = spy.mock.calls.find(([m]) => m === "session.resume")![1] as any; + expect(payload.availableTools).toEqual(["builtin:view", "builtin:task_complete"]); + expect(payload.toolFilterPrecedence).toBe("excluded"); + }); +}); diff --git a/test/snapshots/mode_empty/empty_mode___builtin___exposes_all_built_in_tools.yaml b/test/snapshots/mode_empty/empty_mode___builtin___exposes_all_built_in_tools.yaml new file mode 100644 index 000000000..701fde22e --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode___builtin___exposes_all_built_in_tools.yaml @@ -0,0 +1,8 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi. diff --git a/test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml b/test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml new file mode 100644 index 000000000..701fde22e --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml @@ -0,0 +1,8 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi. diff --git a/test/snapshots/mode_empty/empty_mode___isolated_set__shell_tool_is_not_exposed.yaml b/test/snapshots/mode_empty/empty_mode___isolated_set__shell_tool_is_not_exposed.yaml new file mode 100644 index 000000000..701fde22e --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode___isolated_set__shell_tool_is_not_exposed.yaml @@ -0,0 +1,8 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi.