From 78e7280c2877d97b6329305cecd5f972d5af36c6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 26 May 2026 13:36:18 +0100 Subject: [PATCH 01/11] Add CopilotClientMode + ToolSet builder for Empty mode (#7155) Adds Node SDK surface for the multitenancy hardening work in github/copilot-agent-runtime#7155 (runtime PR #8760). - New `mode: "empty" | "copilot-cli"` on CopilotClientOptions; empty mode requires baseDirectory or sessionFs and rejects sessions without explicit availableTools. - New ToolSet builder + BuiltInTools.Isolated constant for ergonomic, source-qualified tool patterns (builtin:*, mcp:*, custom:*). - availableTools / excludedTools now accept ToolSet or string[]; bare "*" is rejected with a clear error pointing at the source-qualified forms. - New toolFilterMode option ("allowPrecedence" | "denyPrecedence"); empty mode defaults to denyPrecedence so apps can compose include+exclude. - Unit tests (18) and e2e tests (3) including recorded CapiProxy snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 113 ++++++++- nodejs/src/index.ts | 2 + nodejs/src/toolSet.ts | 140 +++++++++++ nodejs/src/types.ts | 65 ++++- nodejs/test/e2e/mode_empty.e2e.test.ts | 101 ++++++++ nodejs/test/toolSet.test.ts | 234 ++++++++++++++++++ ..._builtin___exposes_all_built_in_tools.yaml | 8 + ...edtools_subtracts_from_availabletools.yaml | 8 + ...olated_set__shell_tool_is_not_exposed.yaml | 8 + 9 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 nodejs/src/toolSet.ts create mode 100644 nodejs/test/e2e/mode_empty.e2e.test.ts create mode 100644 nodejs/test/toolSet.test.ts create mode 100644 test/snapshots/mode_empty/empty_mode___builtin___exposes_all_built_in_tools.yaml create mode 100644 test/snapshots/mode_empty/empty_mode___denyprecedence_default__excludedtools_subtracts_from_availabletools.yaml create mode 100644 test/snapshots/mode_empty/empty_mode___isolated_set__shell_tool_is_not_exposed.yaml diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 11e6131cb..8f20baadc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -36,9 +36,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, @@ -129,6 +131,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." + ); + } + } +} + /** * Extract transform callbacks from a system message config and prepare the wire payload. * Function-valued actions are replaced with `{ action: "transform" }` for serialization, @@ -267,6 +301,7 @@ export class CopilotClient { baseDirectory?: string; sessionIdleTimeoutSeconds: number; enableRemoteSessions: boolean; + mode: CopilotClientMode; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -414,7 +449,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[] = []; @@ -789,6 +846,48 @@ 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"`, and applies the + * mode-aware default for `toolFilterMode`. + * + * @internal + */ + private resolveToolFilterOptions(config: { + availableTools?: string[] | ToolSet; + excludedTools?: string[] | ToolSet; + toolFilterMode?: "allowPrecedence" | "denyPrecedence"; + }): { + availableTools: string[] | undefined; + excludedTools: string[] | undefined; + toolFilterMode: "allowPrecedence" | "denyPrecedence" | undefined; + } { + 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)`." + ); + } + } + + // Empty mode flips the default to deny-precedence so apps can compose + // include + exclude lists naturally (e.g. "everything matching X + // except Y"). Callers can still override this explicitly. + const toolFilterMode = + config.toolFilterMode ?? (this.options.mode === "empty" ? "denyPrecedence" : undefined); + + return { availableTools, excludedTools, toolFilterMode }; + } + async createSession(config: SessionConfig): Promise { if (!this.connection) { await this.start(); @@ -838,6 +937,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)), @@ -861,8 +962,9 @@ export class CopilotClient { description: cmd.description, })), systemMessage: wireSystemMessage, - availableTools: config.availableTools, - excludedTools: config.excludedTools, + availableTools: toolFilterOptions.availableTools, + excludedTools: toolFilterOptions.excludedTools, + toolFilterMode: toolFilterOptions.toolFilterMode, provider: config.provider, enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, @@ -977,6 +1079,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)), @@ -985,8 +1089,9 @@ export class CopilotClient { model: config.model, reasoningEffort: config.reasoningEffort, systemMessage: wireSystemMessage, - availableTools: config.availableTools, - excludedTools: config.excludedTools, + availableTools: toolFilterOptions.availableTools, + excludedTools: toolFilterOptions.excludedTools, + toolFilterMode: toolFilterOptions.toolFilterMode, enableSessionTelemetry: config.enableSessionTelemetry, tools: config.tools?.map((tool) => ({ name: tool.name, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c39621c0b..7181d4147 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, @@ -50,6 +51,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 333079e1f..0c3e175ba 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. @@ -1580,15 +1609,41 @@ 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. + * + * Interacts with {@link excludedTools} per + * {@link SessionConfigBase.toolFilterMode}. */ - 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}. + * + * Interacts with {@link availableTools} per + * {@link SessionConfigBase.toolFilterMode}: by default (`"allowPrecedence"`), + * `excludedTools` is ignored whenever `availableTools` is set; under + * `"denyPrecedence"` it always takes effect. */ - excludedTools?: string[]; + excludedTools?: string[] | ToolSet; + + /** + * Controls how {@link availableTools} and {@link excludedTools} combine + * when both are set. + * + * - `"allowPrecedence"` (default): If `availableTools` is set, `excludedTools` + * is ignored. Preserves the historical CLI behavior. + * - `"denyPrecedence"`: `excludedTools` always wins. Enables "everything + * matching X, except Y" patterns. + * + * When the client is in `Mode = "empty"`, the SDK defaults this to + * `"denyPrecedence"` so apps can subtract from `BuiltInTools.Isolated` etc. + */ + toolFilterMode?: "allowPrecedence" | "denyPrecedence"; /** * 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..ee87c5d50 --- /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 + denyPrecedence 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..e346c56f0 --- /dev/null +++ b/nodejs/test/toolSet.test.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * 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("forwards toolFilterMode unchanged when set explicitly", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + excludedTools: ["builtin:bash"], + toolFilterMode: "denyPrecedence", + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.toolFilterMode).toBe("denyPrecedence"); + }); + + it("does not default toolFilterMode 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.toolFilterMode).toBeUndefined(); + }); + + it("defaults toolFilterMode to denyPrecedence 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.toolFilterMode).toBe("denyPrecedence"); + }); + + it("empty-mode default can be overridden to allowPrecedence", async () => { + const { client, spy } = await setupClient("empty"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + toolFilterMode: "allowPrecedence", + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.toolFilterMode).toBe("allowPrecedence"); + }); + + 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.toolFilterMode).toBe("denyPrecedence"); + }); +}); 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___denyprecedence_default__excludedtools_subtracts_from_availabletools.yaml b/test/snapshots/mode_empty/empty_mode___denyprecedence_default__excludedtools_subtracts_from_availabletools.yaml new file mode 100644 index 000000000..701fde22e --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode___denyprecedence_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. From 64df9aa1c0cc09a715ae5abcd9b15e03d7895036 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 26 May 2026 13:46:30 +0100 Subject: [PATCH 02/11] Hardcode toolFilterMode=denyPrecedence on SDK; drop public toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK no longer exposes 'toolFilterMode'. Every session.create / session.resume request now sends toolFilterMode: 'denyPrecedence' unconditionally, so SDK callers always get composable include+exclude semantics (a tool is enabled when it matches availableTools — or availableTools is unset — AND it does not match excludedTools). Allowlist-precedence remains available on the runtime side as a CLI-only concession to legacy behavior; SDK consumers don't need it and the toggle was just extra surface area. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 20 +++++++++----------- nodejs/src/types.ts | 25 +++++-------------------- nodejs/test/toolSet.test.ts | 29 +++-------------------------- 3 files changed, 17 insertions(+), 57 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8f20baadc..521fdf69f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -849,19 +849,23 @@ 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"`, and applies the - * mode-aware default for `toolFilterMode`. + * missing-availableTools case in `mode = "empty"`. + * + * The SDK always sends `toolFilterMode: "denyPrecedence"` 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; - toolFilterMode?: "allowPrecedence" | "denyPrecedence"; }): { availableTools: string[] | undefined; excludedTools: string[] | undefined; - toolFilterMode: "allowPrecedence" | "denyPrecedence" | undefined; + toolFilterMode: "denyPrecedence"; } { const availableTools = toolFilterListToArray(config.availableTools); const excludedTools = toolFilterListToArray(config.excludedTools); @@ -879,13 +883,7 @@ export class CopilotClient { } } - // Empty mode flips the default to deny-precedence so apps can compose - // include + exclude lists naturally (e.g. "everything matching X - // except Y"). Callers can still override this explicitly. - const toolFilterMode = - config.toolFilterMode ?? (this.options.mode === "empty" ? "denyPrecedence" : undefined); - - return { availableTools, excludedTools, toolFilterMode }; + return { availableTools, excludedTools, toolFilterMode: "denyPrecedence" }; } async createSession(config: SessionConfig): Promise { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 0c3e175ba..ae745d244 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1615,8 +1615,9 @@ export interface SessionConfigBase { * name form (exact match across any source). Build this list with * {@link ToolSet} for type safety and readable intent. * - * Interacts with {@link excludedTools} per - * {@link SessionConfigBase.toolFilterMode}. + * 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[] | ToolSet; @@ -1624,27 +1625,11 @@ export interface SessionConfigBase { * List of tool names to disable. Supports the same pattern syntax as * {@link availableTools}. * - * Interacts with {@link availableTools} per - * {@link SessionConfigBase.toolFilterMode}: by default (`"allowPrecedence"`), - * `excludedTools` is ignored whenever `availableTools` is set; under - * `"denyPrecedence"` it always takes effect. + * Always takes precedence over {@link availableTools}: a tool listed here + * is disabled even if it also matches `availableTools`. */ excludedTools?: string[] | ToolSet; - /** - * Controls how {@link availableTools} and {@link excludedTools} combine - * when both are set. - * - * - `"allowPrecedence"` (default): If `availableTools` is set, `excludedTools` - * is ignored. Preserves the historical CLI behavior. - * - `"denyPrecedence"`: `excludedTools` always wins. Enables "everything - * matching X, except Y" patterns. - * - * When the client is in `Mode = "empty"`, the SDK defaults this to - * `"denyPrecedence"` so apps can subtract from `BuiltInTools.Isolated` etc. - */ - toolFilterMode?: "allowPrecedence" | "denyPrecedence"; - /** * Custom provider configuration (BYOK - Bring Your Own Key). * When specified, uses the provided API endpoint instead of the Copilot API. diff --git a/nodejs/test/toolSet.test.ts b/nodejs/test/toolSet.test.ts index e346c56f0..7fc37169b 100644 --- a/nodejs/test/toolSet.test.ts +++ b/nodejs/test/toolSet.test.ts @@ -174,29 +174,17 @@ describe("Tool filter wiring", () => { ).rejects.toThrowError(/bare wildcard/); }); - it("forwards toolFilterMode unchanged when set explicitly", async () => { - const { client, spy } = await setupClient(); - await client.createSession({ - onPermissionRequest: approveAll, - availableTools: ["builtin:bash"], - excludedTools: ["builtin:bash"], - toolFilterMode: "denyPrecedence", - }); - const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; - expect(payload.toolFilterMode).toBe("denyPrecedence"); - }); - - it("does not default toolFilterMode in copilot-cli mode", async () => { + it("always sends toolFilterMode: denyPrecedence 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.toolFilterMode).toBeUndefined(); + expect(payload.toolFilterMode).toBe("denyPrecedence"); }); - it("defaults toolFilterMode to denyPrecedence in empty mode", async () => { + it("always sends toolFilterMode: denyPrecedence in empty mode", async () => { const { client, spy } = await setupClient("empty"); await client.createSession({ onPermissionRequest: approveAll, @@ -206,17 +194,6 @@ describe("Tool filter wiring", () => { expect(payload.toolFilterMode).toBe("denyPrecedence"); }); - it("empty-mode default can be overridden to allowPrecedence", async () => { - const { client, spy } = await setupClient("empty"); - await client.createSession({ - onPermissionRequest: approveAll, - availableTools: ["builtin:bash"], - toolFilterMode: "allowPrecedence", - }); - const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; - expect(payload.toolFilterMode).toBe("allowPrecedence"); - }); - it("applies the same filter normalization on session.resume", async () => { const { client, spy } = await setupClient("empty"); const session = await client.createSession({ From a41b260550ed2631574f4841e8485c42abacf956 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 26 May 2026 15:27:10 +0100 Subject: [PATCH 03/11] Mirror runtime renames: toolFilterMode -> toolFilterPrecedence, denyPrecedence -> excluded Mirrors the rename landed in the runtime PR. Also regenerates rpc.ts to pick up the new toolFilterPrecedence field on SessionUpdateOptionsParams, and renames the corresponding E2E capture snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 10 +++++----- nodejs/src/generated/rpc.ts | 13 +++++++++++++ nodejs/test/e2e/mode_empty.e2e.test.ts | 2 +- nodejs/test/toolSet.test.ts | 10 +++++----- ...xcludedtools_subtracts_from_availabletools.yaml} | 0 5 files changed, 24 insertions(+), 11 deletions(-) rename test/snapshots/mode_empty/{empty_mode___denyprecedence_default__excludedtools_subtracts_from_availabletools.yaml => empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml} (100%) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 521fdf69f..4bd47a887 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -851,7 +851,7 @@ export class CopilotClient { * instances to plain string arrays, rejects misuse (bare `"*"`) and the * missing-availableTools case in `mode = "empty"`. * - * The SDK always sends `toolFilterMode: "denyPrecedence"` so callers can + * 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 @@ -865,7 +865,7 @@ export class CopilotClient { }): { availableTools: string[] | undefined; excludedTools: string[] | undefined; - toolFilterMode: "denyPrecedence"; + toolFilterPrecedence: "excluded"; } { const availableTools = toolFilterListToArray(config.availableTools); const excludedTools = toolFilterListToArray(config.excludedTools); @@ -883,7 +883,7 @@ export class CopilotClient { } } - return { availableTools, excludedTools, toolFilterMode: "denyPrecedence" }; + return { availableTools, excludedTools, toolFilterPrecedence: "excluded" }; } async createSession(config: SessionConfig): Promise { @@ -962,7 +962,7 @@ export class CopilotClient { systemMessage: wireSystemMessage, availableTools: toolFilterOptions.availableTools, excludedTools: toolFilterOptions.excludedTools, - toolFilterMode: toolFilterOptions.toolFilterMode, + toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, provider: config.provider, enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, @@ -1089,7 +1089,7 @@ export class CopilotClient { systemMessage: wireSystemMessage, availableTools: toolFilterOptions.availableTools, excludedTools: toolFilterOptions.excludedTools, - toolFilterMode: toolFilterOptions.toolFilterMode, + 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 c79da28e9..99acba281 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -669,6 +669,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 * @@ -7762,6 +7774,7 @@ export interface SessionUpdateOptionsParams { */ logInteractiveShells?: boolean; envValueMode?: OptionsUpdateEnvValueMode; + toolFilterPrecedence?: OptionsUpdateToolFilterPrecedence; /** * Additional directories to search for skills. */ diff --git a/nodejs/test/e2e/mode_empty.e2e.test.ts b/nodejs/test/e2e/mode_empty.e2e.test.ts index ee87c5d50..df19a9ce7 100644 --- a/nodejs/test/e2e/mode_empty.e2e.test.ts +++ b/nodejs/test/e2e/mode_empty.e2e.test.ts @@ -81,7 +81,7 @@ describe("Mode = empty + ToolSet patterns", async () => { await session.disconnect(); }); - it("empty mode + denyPrecedence default: excludedTools subtracts from availableTools", async () => { + it("empty mode + excluded default: excludedTools subtracts from availableTools", async () => { const shellToolName = process.platform === "win32" ? "powershell" : "bash"; const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/nodejs/test/toolSet.test.ts b/nodejs/test/toolSet.test.ts index 7fc37169b..2b9ce93cd 100644 --- a/nodejs/test/toolSet.test.ts +++ b/nodejs/test/toolSet.test.ts @@ -174,24 +174,24 @@ describe("Tool filter wiring", () => { ).rejects.toThrowError(/bare wildcard/); }); - it("always sends toolFilterMode: denyPrecedence in copilot-cli mode", async () => { + 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.toolFilterMode).toBe("denyPrecedence"); + expect(payload.toolFilterPrecedence).toBe("excluded"); }); - it("always sends toolFilterMode: denyPrecedence in empty mode", async () => { + 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.toolFilterMode).toBe("denyPrecedence"); + expect(payload.toolFilterPrecedence).toBe("excluded"); }); it("applies the same filter normalization on session.resume", async () => { @@ -206,6 +206,6 @@ describe("Tool filter wiring", () => { }); const payload = spy.mock.calls.find(([m]) => m === "session.resume")![1] as any; expect(payload.availableTools).toEqual(["builtin:view", "builtin:task_complete"]); - expect(payload.toolFilterMode).toBe("denyPrecedence"); + expect(payload.toolFilterPrecedence).toBe("excluded"); }); }); diff --git a/test/snapshots/mode_empty/empty_mode___denyprecedence_default__excludedtools_subtracts_from_availabletools.yaml b/test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml similarity index 100% rename from test/snapshots/mode_empty/empty_mode___denyprecedence_default__excludedtools_subtracts_from_availabletools.yaml rename to test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml From d58befa404e502995b915fc6a42fd28539687587 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 13:24:24 +0100 Subject: [PATCH 04/11] Empty mode: wire safe defaults for ambient session knobs Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 76 +++++++++++- nodejs/test/toolSet.test.ts | 231 ++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 1 deletion(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4bd47a887..f91e76ca9 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -54,6 +54,8 @@ import type { ResumeSessionConfig, SectionTransformFn, SessionConfig, + SessionConfigBase, + SystemMessageConfig, SessionCapabilities, SessionEvent, SessionFsConfig, @@ -886,11 +888,77 @@ export class CopilotClient { return { availableTools, excludedTools, toolFilterPrecedence: "excluded" }; } + /** Mode-specific defaults spread under the caller's config (app values win). */ + private configDefaultsForMode(): Partial { + if (this.options.mode === "empty") { + return { enableSessionTelemetry: false }; + } + return {}; + } + + /** + * Returns the systemMessage config to use, adjusted for the current mode. + * In empty mode we ensure the environment_context section is removed + * unless the app has already taken control of it; append mode is rejected + * because it would leave environment info in the prompt. + */ + private getSystemMessageConfigForMode( + supplied: SystemMessageConfig | undefined + ): SystemMessageConfig | undefined { + if (this.options.mode !== "empty") return supplied; + if (!supplied) { + return { + mode: "customize", + sections: { environment_context: { action: "remove" } }, + }; + } + switch (supplied.mode) { + case "replace": + return supplied; + case "customize": + if (supplied.sections?.environment_context) return supplied; + return { + ...supplied, + sections: { + ...supplied.sections, + environment_context: { action: "remove" }, + }, + }; + case "append": + case undefined: + // Promote to customize so we can also strip environment_context. + // The runtime appends `content` to additional instructions in + // both customize and append modes, so the caller's text is + // preserved verbatim. + return { + mode: "customize", + content: supplied.content, + sections: { environment_context: { action: "remove" } }, + }; + } + } + + /** Mode-specific options applied via session.options.update after create/resume. */ + private async updateSessionOptionsForMode(session: CopilotSession): Promise { + if (this.options.mode === "empty") { + await session.rpc.options.update({ + skipCustomInstructions: true, + customAgentsLocalOnly: true, + coauthorEnabled: false, + manageScheduleEnabled: false, + installedPlugins: [], + }); + } + } + async createSession(config: SessionConfig): Promise { if (!this.connection) { await this.start(); } + config = { ...this.configDefaultsForMode(), ...config }; + config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage); + const sessionId = config.sessionId ?? randomUUID(); // Create and register the session before issuing the RPC so that @@ -998,6 +1066,8 @@ export class CopilotClient { }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); + + await this.updateSessionOptionsForMode(session); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1063,7 +1133,9 @@ export class CopilotClient { session.registerHooks(config.hooks); } - // Extract transform callbacks from system message config before serialization. + config = { ...this.configDefaultsForMode(), ...config }; + config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage); + const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( config.systemMessage ); @@ -1145,6 +1217,8 @@ export class CopilotClient { session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); session.setOpenCanvases(openCanvases ?? []); + + await this.updateSessionOptionsForMode(session); } catch (e) { this.sessions.delete(sessionId); throw e; diff --git a/nodejs/test/toolSet.test.ts b/nodejs/test/toolSet.test.ts index 2b9ce93cd..eab198b31 100644 --- a/nodejs/test/toolSet.test.ts +++ b/nodejs/test/toolSet.test.ts @@ -129,6 +129,9 @@ describe("Tool filter wiring", () => { if (method === "session.create" || method === "session.resume") { return { sessionId: params.sessionId }; } + if (method === "session.options.update") { + return { success: true }; + } throw new Error(`Unexpected method: ${method}`); }); return { client, spy }; @@ -209,3 +212,231 @@ describe("Tool filter wiring", () => { expect(payload.toolFilterPrecedence).toBe("excluded"); }); }); + +describe("Empty-mode safe defaults", () => { + async function setupClient(mode: "empty" | "copilot-cli" = "empty") { + 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 }; + } + if (method === "session.options.update") { + return { success: true }; + } + throw new Error(`Unexpected method: ${method}`); + }); + return { client, spy }; + } + + function createPayload(spy: ReturnType) { + return (spy as any).mock.calls.find(([m]: [string]) => m === "session.create")![1] as any; + } + + function patchCall(spy: ReturnType) { + return (spy as any).mock.calls.find( + ([m]: [string]) => m === "session.options.update" + )![1] as any; + } + + it("forces enableSessionTelemetry=false when app didn't opt in", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + expect(createPayload(spy).enableSessionTelemetry).toBe(false); + }); + + it("respects app-supplied enableSessionTelemetry=true override", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + enableSessionTelemetry: true, + }); + expect(createPayload(spy).enableSessionTelemetry).toBe(true); + }); + + it("injects environment_context removal when app didn't pass systemMessage", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const payload = createPayload(spy); + expect(payload.systemMessage).toEqual({ + mode: "customize", + sections: { environment_context: { action: "remove" } }, + }); + }); + + it("passes through app-supplied systemMessage in replace mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { mode: "replace", content: "you are a haiku bot" }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "replace", + content: "you are a haiku bot", + }); + }); + + it("promotes append-mode systemMessage to customize with env_context removal in empty mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { mode: "append", content: "extra rules" }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "customize", + content: "extra rules", + sections: { environment_context: { action: "remove" } }, + }); + }); + + it("promotes default-mode (append) systemMessage in empty mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { content: "extra rules" }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "customize", + content: "extra rules", + sections: { environment_context: { action: "remove" } }, + }); + }); + + it("adds environment_context removal to customize mode when app didn't set it", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "customize", + sections: { tool_use: { action: "remove" } }, + }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "customize", + sections: { + tool_use: { action: "remove" }, + environment_context: { action: "remove" }, + }, + }); + }); + + it("leaves customize-mode systemMessage alone when app set environment_context", async () => { + const { client, spy } = await setupClient(); + const supplied = { + mode: "customize" as const, + sections: { + environment_context: { action: "replace" as const, content: "custom env" }, + }, + }; + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: supplied, + }); + expect(createPayload(spy).systemMessage).toEqual(supplied); + }); + + it("sends session.options.update with safe defaults after session.create", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const patch = patchCall(spy); + expect(patch).toMatchObject({ + skipCustomInstructions: true, + customAgentsLocalOnly: true, + coauthorEnabled: false, + manageScheduleEnabled: false, + installedPlugins: [], + }); + expect(patch.sessionId).toBeDefined(); + }); + + it("sends the patch AFTER session.create succeeds (order matters)", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const methods = spy.mock.calls.map(([m]) => m); + const createIdx = methods.indexOf("session.create"); + const patchIdx = methods.indexOf("session.options.update"); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(patchIdx).toBeGreaterThan(createIdx); + }); + + it("does NOT send patch or systemMessage override in copilot-cli mode", async () => { + const { client, spy } = await setupClient("copilot-cli"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + }); + const methods = spy.mock.calls.map(([m]) => m); + expect(methods).not.toContain("session.options.update"); + expect(createPayload(spy).systemMessage).toBeUndefined(); + expect(createPayload(spy).enableSessionTelemetry).toBeUndefined(); + }); + + it("tears the session down if the post-create patch fails", async () => { + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); + await client.start(); + onTestFinished(() => client.forceStop()); + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.options.update") { + throw new Error("update rejected"); + } + throw new Error(`Unexpected method: ${method}`); + } + ); + await expect( + client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }) + ).rejects.toThrowError(/update rejected/); + // Session must not remain registered after the failed patch. + expect((client as any).sessions.size).toBe(0); + }); + + it("also applies overrides on session.resume", async () => { + const { client, spy } = await setupClient(); + // First create so we have a session id to resume. + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + spy.mockClear(); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const resumePayload = spy.mock.calls.find(([m]) => m === "session.resume")![1] as any; + expect(resumePayload.enableSessionTelemetry).toBe(false); + expect(resumePayload.systemMessage).toEqual({ + mode: "customize", + sections: { environment_context: { action: "remove" } }, + }); + const patch = spy.mock.calls.find(([m]) => m === "session.options.update")![1] as any; + expect(patch.skipCustomInstructions).toBe(true); + }); +}); From 911ecce180b76e974fc49363821b7321b30bb189 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 14:16:10 +0100 Subject: [PATCH 05/11] Add e2e tests for empty-mode systemMessage defaults Three deterministic tests using sendAndWait + element-name instructions: - default: env_context stripped (ARGON) - replace: caller content used verbatim (KRYPTON) - append: caller instruction applied and env_context still stripped (XENON) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/mode_empty.e2e.test.ts | 85 +++++++++++++++++++ ...ect_and_env_context_is_still_stripped.yaml | 10 +++ ...llm_follows_caller_s_content_verbatim.yaml | 10 +++ ...xt_from_the_system_message_by_default.yaml | 10 +++ 4 files changed, 115 insertions(+) create mode 100644 test/snapshots/mode_empty/empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml create mode 100644 test/snapshots/mode_empty/empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml create mode 100644 test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml diff --git a/nodejs/test/e2e/mode_empty.e2e.test.ts b/nodejs/test/e2e/mode_empty.e2e.test.ts index df19a9ce7..e39506a40 100644 --- a/nodejs/test/e2e/mode_empty.e2e.test.ts +++ b/nodejs/test/e2e/mode_empty.e2e.test.ts @@ -44,6 +44,28 @@ describe("Mode = empty + ToolSet patterns", async () => { ); } + async function getSystemMessageSentToLLM(): 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 messages = exchanges[exchanges.length - 1].request.messages ?? []; + const sys = messages.find((m) => m.role === "system"); + const content = sys?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((p) => (typeof p === "object" && p && "text" in p ? p.text : "")) + .join("\n"); + } + return ""; + } + it("empty mode + Isolated set: shell tool is NOT exposed", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, @@ -98,4 +120,67 @@ describe("Mode = empty + ToolSet patterns", async () => { await session.disconnect(); }); + + it("empty mode strips environment_context from the system message by default", async () => { + // We can't directly observe section presence, but we can detect it + // indirectly: in default empty mode the SDK injects the customize-mode + // override `environment_context: { action: "remove" }`. We also append + // a deterministic instruction. If the env_context strip didn't fire, + // the runtime would still inject OS/cwd lines into the system message + // and the model would be free to mention them; with the strip in place + // the model has no env info to lean on and follows our instruction. + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "customize", + content: + "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + }, + }); + const reply = await session.sendAndWait({ prompt: "Name an element." }); + expect(reply?.data.content).toContain("ARGON"); + + const systemMessage = await getSystemMessageSentToLLM(); + expect(systemMessage).not.toMatch(/Current working directory:/i); + expect(systemMessage).not.toMatch(/Operating System:/i); + + await session.disconnect(); + }); + + it("empty mode + systemMessage replace: LLM follows caller's content verbatim", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "replace", + content: + "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + }, + }); + const reply = await session.sendAndWait({ prompt: "Hello." }); + expect(reply?.data.content).toContain("KRYPTON"); + + await session.disconnect(); + }); + + it("empty mode + append: caller's instruction takes effect and env_context is still stripped", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "append", + content: + "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + }, + }); + const reply = await session.sendAndWait({ prompt: "Name a noble gas." }); + expect(reply?.data.content).toContain("XENON"); + + const systemMessage = await getSystemMessageSentToLLM(); + expect(systemMessage).not.toMatch(/Current working directory:/i); + expect(systemMessage).not.toMatch(/Operating System:/i); + + await session.disconnect(); + }); }); diff --git a/test/snapshots/mode_empty/empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml b/test/snapshots/mode_empty/empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml new file mode 100644 index 000000000..fac88270d --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Name a noble gas. + - role: assistant + content: XENON diff --git a/test/snapshots/mode_empty/empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml b/test/snapshots/mode_empty/empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml new file mode 100644 index 000000000..5d63a9401 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Hello. + - role: assistant + content: KRYPTON diff --git a/test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml b/test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml new file mode 100644 index 000000000..6f23714d9 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Name an element. + - role: assistant + content: ARGON From 61f8694241526eec6877422c80593a48c316e600 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 14:28:11 +0100 Subject: [PATCH 06/11] Expose four post-create flags on SessionConfig + set COPILOT_DISABLE_KEYTAR in empty mode - Add skipCustomInstructions, customAgentsLocalOnly, coauthorEnabled, manageScheduleEnabled to SessionConfigBase. App-supplied values now win over the empty-mode defaults that are otherwise forced via the post-create session.options.update patch. Also forwarded in copilot-cli mode when the app sets them. - Set COPILOT_DISABLE_KEYTAR=1 on the runtime spawn env when mode === 'empty', so the runtime skips the process-wide system keychain (unsafe for multi-tenant hosts) and falls back to file-based credentials scoped to COPILOT_HOME. - 31/31 unit tests passing (2 new covering the override and copilot-cli forwarding paths). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 52 ++++++++++++++++++++++++++++--------- nodejs/src/types.ts | 37 ++++++++++++++++++++++++++ nodejs/test/toolSet.test.ts | 38 +++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index f91e76ca9..8df636c73 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,7 +31,7 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; -import type { OpenCanvasInstance } from "./generated/rpc.js"; +import type { OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -938,16 +938,36 @@ export class CopilotClient { } } - /** Mode-specific options applied via session.options.update after create/resume. */ - private async updateSessionOptionsForMode(session: CopilotSession): Promise { + /** + * Mode-specific options applied via session.options.update after create/resume. + * + * In empty mode, defaults the four overridable feature flags to safe values + * (caller values from `config` win). `installedPlugins=[]` is unconditional + * in empty mode — apps that need custom plugins should switch modes. + */ + private async updateSessionOptionsForMode( + session: CopilotSession, + config: SessionConfigBase + ): Promise { + const patch: SessionUpdateOptionsParams = {}; if (this.options.mode === "empty") { - await session.rpc.options.update({ - skipCustomInstructions: true, - customAgentsLocalOnly: true, - coauthorEnabled: false, - manageScheduleEnabled: false, - installedPlugins: [], - }); + patch.skipCustomInstructions = config.skipCustomInstructions ?? true; + patch.customAgentsLocalOnly = config.customAgentsLocalOnly ?? true; + patch.coauthorEnabled = config.coauthorEnabled ?? false; + patch.manageScheduleEnabled = config.manageScheduleEnabled ?? false; + patch.installedPlugins = []; + } else { + if (config.skipCustomInstructions !== undefined) + patch.skipCustomInstructions = config.skipCustomInstructions; + if (config.customAgentsLocalOnly !== undefined) + patch.customAgentsLocalOnly = config.customAgentsLocalOnly; + if (config.coauthorEnabled !== undefined) + patch.coauthorEnabled = config.coauthorEnabled; + if (config.manageScheduleEnabled !== undefined) + patch.manageScheduleEnabled = config.manageScheduleEnabled; + } + if (Object.keys(patch).length > 0) { + await session.rpc.options.update(patch); } } @@ -1067,7 +1087,7 @@ export class CopilotClient { session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); - await this.updateSessionOptionsForMode(session); + await this.updateSessionOptionsForMode(session, config); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1218,7 +1238,7 @@ export class CopilotClient { session.setCapabilities(capabilities); session.setOpenCanvases(openCanvases ?? []); - await this.updateSessionOptionsForMode(session); + await this.updateSessionOptionsForMode(session, config); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1766,6 +1786,14 @@ export class CopilotClient { envWithoutNodeDebug.COPILOT_HOME = this.options.baseDirectory; } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (this.options.mode === "empty") { + envWithoutNodeDebug.COPILOT_DISABLE_KEYTAR = "1"; + } + if (!this.resolvedCliPath) { throw new Error( "Path to Copilot CLI is required. Please supply it via " + diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ae745d244..7aeb0b162 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1646,6 +1646,43 @@ export interface SessionConfigBase { */ enableSessionTelemetry?: boolean; + /** + * When true, the runtime skips loading custom-instruction sources + * (e.g. `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`). + * + * Defaults to `false` (custom instructions are loaded). Under + * {@link CopilotClientOptions.mode} = `"empty"`, defaults to `true`; apps + * can pass `false` here to opt back in. + */ + skipCustomInstructions?: boolean; + + /** + * When true, custom agents default to local-only execution and are not + * dispatched to remote workers. + * + * Defaults to `false`. Under {@link CopilotClientOptions.mode} = `"empty"`, + * defaults to `true`; apps can pass `false` here to opt back in. + */ + customAgentsLocalOnly?: boolean; + + /** + * When true, the runtime instructs the agent to include a `Co-authored-by` + * trailer in commit messages it composes. + * + * Defaults to `true`. Under {@link CopilotClientOptions.mode} = `"empty"`, + * defaults to `false`; apps can pass `true` here to opt back in. + */ + coauthorEnabled?: boolean; + + /** + * When true, the `manage_schedule` tool is exposed to the agent. + * + * Defaults to whatever the runtime exposes (typically gated to staff + * users). Under {@link CopilotClientOptions.mode} = `"empty"`, defaults to + * `false`; apps can pass `true` here to opt back in. + */ + manageScheduleEnabled?: boolean; + /** * Optional handler for permission requests from the server. * When omitted, permission requests are surfaced as events and left pending for diff --git a/nodejs/test/toolSet.test.ts b/nodejs/test/toolSet.test.ts index eab198b31..ed0d05771 100644 --- a/nodejs/test/toolSet.test.ts +++ b/nodejs/test/toolSet.test.ts @@ -439,4 +439,42 @@ describe("Empty-mode safe defaults", () => { const patch = spy.mock.calls.find(([m]) => m === "session.options.update")![1] as any; expect(patch.skipCustomInstructions).toBe(true); }); + + it("respects app-supplied overrides for the four post-create flags in empty mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + skipCustomInstructions: false, + customAgentsLocalOnly: false, + coauthorEnabled: true, + manageScheduleEnabled: true, + }); + const patch = patchCall(spy); + expect(patch).toMatchObject({ + skipCustomInstructions: false, + customAgentsLocalOnly: false, + coauthorEnabled: true, + manageScheduleEnabled: true, + installedPlugins: [], + }); + }); + + it("forwards the four flags in copilot-cli mode when the app sets them", async () => { + const { client, spy } = await setupClient("copilot-cli"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + skipCustomInstructions: true, + manageScheduleEnabled: true, + }); + const patch = patchCall(spy); + expect(patch).toMatchObject({ + skipCustomInstructions: true, + manageScheduleEnabled: true, + }); + expect(patch.customAgentsLocalOnly).toBeUndefined(); + expect(patch.coauthorEnabled).toBeUndefined(); + expect(patch.installedPlugins).toBeUndefined(); + }); }); From f36f62b53e40082ed6a74c048988650ade531b1b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 15:18:22 +0100 Subject: [PATCH 07/11] [C#] Mode=Empty SDK surface and ToolSet builder Port of nodejs/src/client.ts mode=empty work (commits 78e7280c..61f86942) to the .NET SDK. - Add CopilotClientMode enum (Empty, CopilotCli) and Mode field on CopilotClientOptions; validation in constructor requires BaseDirectory, SessionFs, or UriRuntimeConnection when Mode=Empty. - Add ToolSet builder and BuiltInTools.Isolated curated set in dotnet/src/ToolSet.cs. ToolSet inherits from List so instances can be assigned directly to AvailableTools/ExcludedTools. - Tool-filter resolution always emits toolFilterPrecedence=excluded on the wire (CreateSessionRequest/ResumeSessionRequest). Bare "*" rejected. Empty mode requires AvailableTools. - Empty-mode safe defaults applied in Create/Resume: - environment_context stripped from system message - EnableSessionTelemetry=false (caller wins) - post-create session.options.update patch sets installedPlugins=[] plus 4 opt-back-in flags (SkipCustomInstructions=true, CustomAgentsLocalOnly=true, CoauthorEnabled=false, ManageScheduleEnabled=false), caller wins - COPILOT_DISABLE_KEYTAR=1 env in spawned CLI - Add SessionConfigBase fields for the 4 opt-back-in flags. - Add E2E tests at dotnet/test/E2E/ModeEmptyE2ETests.cs (6 tests) sharing recorded cassettes with the Node SDK under test/snapshots/mode_empty/. - Regenerate dotnet/src/Generated/Rpc.cs against runtime branch schema to expose OptionsUpdateToolFilterPrecedence. - Tidy: rename existing mode_empty cassettes to clean snake_case names shared across all language SDKs; update Node test titles to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 238 +++++++++++++++++- dotnet/src/Generated/Rpc.cs | 72 +++++- dotnet/src/ToolSet.cs | 156 ++++++++++++ dotnet/src/Types.cs | 97 +++++++ dotnet/test/E2E/ModeEmptyE2ETests.cs | 199 +++++++++++++++ go/rpc/zrpc.go | 22 ++ nodejs/test/e2e/mode_empty.e2e.test.ts | 10 +- python/copilot/generated/rpc.py | 22 +- rust/src/generated/api_types.rs | 28 ++- ...akes_effect_and_env_context_stripped.yaml} | 0 ...ltin_star_exposes_all_built_in_tools.yaml} | 0 ...tools_subtracts_from_available_tools.yaml} | 0 ...olated_set_shell_tool_is_not_exposed.yaml} | 0 ..._llm_follows_caller_content_verbatim.yaml} | 0 14 files changed, 826 insertions(+), 18 deletions(-) create mode 100644 dotnet/src/ToolSet.cs create mode 100644 dotnet/test/E2E/ModeEmptyE2ETests.cs rename test/snapshots/mode_empty/{empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml => empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml} (100%) rename test/snapshots/mode_empty/{empty_mode___builtin___exposes_all_built_in_tools.yaml => empty_mode_builtin_star_exposes_all_built_in_tools.yaml} (100%) rename test/snapshots/mode_empty/{empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml => empty_mode_excluded_tools_subtracts_from_available_tools.yaml} (100%) rename test/snapshots/mode_empty/{empty_mode___isolated_set__shell_tool_is_not_exposed.yaml => empty_mode_isolated_set_shell_tool_is_not_exposed.yaml} (100%) rename test/snapshots/mode_empty/{empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml => empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml} (100%) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 4a65780bd..1e8bce0be 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -162,6 +162,28 @@ public CopilotClient(CopilotClientOptions? options = null) _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; + + // 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 (_options.Mode == CopilotClientMode.Empty) + { + var hasPersistence = + !string.IsNullOrEmpty(_options.BaseDirectory) || + _options.SessionFs is not null || + // External runtimes manage their own persistence layer; the SDK + // can't enforce it from here. + _connection is UriRuntimeConnection; + if (!hasPersistence) + { + throw new ArgumentException( + "CopilotClient was created with Mode = CopilotClientMode.Empty but neither " + + "BaseDirectory nor SessionFs was set. Empty mode requires an explicit " + + "per-session persistence location; pick one.", + nameof(options)); + } + } } /// @@ -491,6 +513,181 @@ private static (SystemMessageConfig? wireConfig, Dictionary + /// Catches misuse of / + /// at the SDK boundary so + /// callers 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. + /// + private static void ValidateToolFilterList(string field, IList? list) + { + if (list is null) return; + foreach (var entry in list) + { + if (entry == "*") + { + throw new ArgumentException( + $"Invalid {field} entry '*': there is no bare wildcard. " + + "Use `new ToolSet().AddBuiltIn(\"*\")`, `.AddMcp(\"*\")`, or " + + "`.AddCustom(\"*\")` to target a specific source.", + nameof(list)); + } + } + } + + /// + /// Resolves / + /// for the wire payload, + /// validating empty-mode requirements. toolFilterPrecedence is + /// always excluded so SDK consumers get composable allowlist / + /// denylist semantics. + /// + private (IList? AvailableTools, IList? ExcludedTools, OptionsUpdateToolFilterPrecedence ToolFilterPrecedence) ResolveToolFilterOptions(SessionConfigBase config) + { + ValidateToolFilterList(nameof(SessionConfigBase.AvailableTools), config.AvailableTools); + ValidateToolFilterList(nameof(SessionConfigBase.ExcludedTools), config.ExcludedTools); + + if (_options.Mode == CopilotClientMode.Empty && config.AvailableTools is null) + { + throw new ArgumentException( + "CopilotClient is in Mode = CopilotClientMode.Empty but the session config did " + + "not specify AvailableTools. Empty mode requires every session to explicitly " + + "opt into the tools it wants — e.g. " + + "`AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated)`.", + nameof(config)); + } + + return (config.AvailableTools, config.ExcludedTools, OptionsUpdateToolFilterPrecedence.Excluded); + } + + /// + /// Applies mode-specific defaults to a session config in place. Caller + /// values win — only fields left unset by the caller are filled in. + /// + private void ApplyConfigDefaultsForMode(SessionConfigBase config) + { + if (_options.Mode == CopilotClientMode.Empty) + { + config.EnableSessionTelemetry ??= false; + } + } + + /// + /// Returns the to send to the runtime, + /// adjusted for the current mode. In empty mode the + /// environment_context section is stripped unless the caller has + /// already taken control of it; append-mode messages are promoted to + /// customize so the env-context strip can apply alongside the caller's + /// content (the runtime appends + /// in both modes). + /// + private SystemMessageConfig? GetSystemMessageConfigForMode(SystemMessageConfig? supplied) + { + if (_options.Mode != CopilotClientMode.Empty) + { + return supplied; + } + + if (supplied is null) + { + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + [SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove }, + }, + }; + } + + switch (supplied.Mode) + { + case SystemMessageMode.Replace: + return supplied; + case SystemMessageMode.Customize: + if (supplied.Sections is not null && supplied.Sections.ContainsKey(SystemMessageSection.EnvironmentContext)) + { + return supplied; + } + var mergedSections = supplied.Sections is null + ? new Dictionary() + : new Dictionary(supplied.Sections); + mergedSections[SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove }; + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Content = supplied.Content, + Sections = mergedSections, + }; + case SystemMessageMode.Append: + case null: + // Promote to customize so we can also strip environment_context. + // The runtime appends Content to additional instructions in both + // customize and append modes, so the caller's text is preserved. + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Content = supplied.Content, + Sections = new Dictionary + { + [SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove }, + }, + }; + default: + return supplied; + } + } + + /// + /// Applies the post-create / post-resume session.options.update + /// patch for the current mode. In empty mode this defaults the four + /// overridable feature flags to safe values (caller values from + /// win); installedPlugins=[] is + /// unconditional under empty mode so apps that need plugins must switch + /// modes. In copilot-cli mode only explicitly-set fields are forwarded. + /// + private async Task UpdateSessionOptionsForModeAsync(CopilotSession session, SessionConfigBase config, CancellationToken cancellationToken) + { + var hasAnyPatch = false; + bool? skipCustomInstructions = null; + bool? customAgentsLocalOnly = null; + bool? coauthorEnabled = null; + bool? manageScheduleEnabled = null; + IList? installedPlugins = null; + + if (_options.Mode == CopilotClientMode.Empty) + { + skipCustomInstructions = config.SkipCustomInstructions ?? true; + customAgentsLocalOnly = config.CustomAgentsLocalOnly ?? true; + coauthorEnabled = config.CoauthorEnabled ?? false; + manageScheduleEnabled = config.ManageScheduleEnabled ?? false; + installedPlugins = new List(); + hasAnyPatch = true; + } + else + { + if (config.SkipCustomInstructions is not null) { skipCustomInstructions = config.SkipCustomInstructions; hasAnyPatch = true; } + if (config.CustomAgentsLocalOnly is not null) { customAgentsLocalOnly = config.CustomAgentsLocalOnly; hasAnyPatch = true; } + if (config.CoauthorEnabled is not null) { coauthorEnabled = config.CoauthorEnabled; hasAnyPatch = true; } + if (config.ManageScheduleEnabled is not null) { manageScheduleEnabled = config.ManageScheduleEnabled; hasAnyPatch = true; } + } + + if (!hasAnyPatch) return; + +#pragma warning disable GHCP001 + await session.Rpc.Options.UpdateAsync( + skipCustomInstructions: skipCustomInstructions, + customAgentsLocalOnly: customAgentsLocalOnly, + coauthorEnabled: coauthorEnabled, + manageScheduleEnabled: manageScheduleEnabled, + installedPlugins: installedPlugins, + cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning restore GHCP001 + } + /// /// Creates a new Copilot session with the specified configuration. /// @@ -523,6 +720,10 @@ public async Task CreateSessionAsync(SessionConfig config, Cance var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); + ApplyConfigDefaultsForMode(config); + config.SystemMessage = GetSystemMessageConfigForMode(config.SystemMessage); + var toolFilter = ResolveToolFilterOptions(config); + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPreMcpToolCall != null || @@ -590,8 +791,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, - config.AvailableTools, - config.ExcludedTools, + toolFilter.AvailableTools, + toolFilter.ExcludedTools, config.Provider, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, @@ -624,7 +825,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Canvases: config.Canvases, RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, - ExtensionInfo: config.ExtensionInfo); + ExtensionInfo: config.ExtensionInfo, + ToolFilterPrecedence: toolFilter.ToolFilterPrecedence); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -637,6 +839,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); session.SetOpenCanvases(response.OpenCanvases); + + await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -691,6 +895,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); + ApplyConfigDefaultsForMode(config); + config.SystemMessage = GetSystemMessageConfigForMode(config.SystemMessage); + var toolFilter = ResolveToolFilterOptions(config); + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPreMcpToolCall != null || @@ -756,8 +964,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, - config.AvailableTools, - config.ExcludedTools, + toolFilter.AvailableTools, + toolFilter.ExcludedTools, config.Provider, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, @@ -792,7 +1000,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, ExtensionInfo: config.ExtensionInfo, - OpenCanvases: config.OpenCanvases); + OpenCanvases: config.OpenCanvases, + ToolFilterPrecedence: toolFilter.ToolFilterPrecedence); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -805,6 +1014,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); session.SetOpenCanvases(response.OpenCanvases); + + await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -1439,6 +1650,15 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) startInfo.Environment["COPILOT_HOME"] = options.BaseDirectory; } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (options.Mode == CopilotClientMode.Empty) + { + startInfo.Environment["COPILOT_DISABLE_KEYTAR"] = "1"; + } + // Set telemetry environment variables if configured if (options.Telemetry is { } telemetry) { @@ -1887,7 +2107,8 @@ internal record CreateSessionRequest( IList? Canvases = null, bool? RequestCanvasRenderer = null, bool? RequestExtensions = null, - ExtensionInfo? ExtensionInfo = null); + ExtensionInfo? ExtensionInfo = null, + OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -1959,7 +2180,8 @@ internal record ResumeSessionRequest( bool? RequestCanvasRenderer = null, bool? RequestExtensions = null, ExtensionInfo? ExtensionInfo = null, - IList? OpenCanvases = null); + IList? OpenCanvases = null, + OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3652fd784..d16f724bd 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -4397,6 +4397,10 @@ internal sealed class SessionUpdateOptionsParams [JsonPropertyName("skipCustomInstructions")] public bool? SkipCustomInstructions { get; set; } + /// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. + [JsonPropertyName("toolFilterPrecedence")] + public OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence { get; set; } + /// Optional path for trajectory output. [JsonPropertyName("trajectoryFile")] public string? TrajectoryFile { get; set; } @@ -10076,6 +10080,69 @@ public override void Write(Utf8JsonWriter writer, OptionsUpdateEnvValueMode valu } +/// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. +[Experimental(Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct OptionsUpdateToolFilterPrecedence : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public OptionsUpdateToolFilterPrecedence(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// If availableTools is set, it is the only constraint that applies (excludedTools is ignored). Preserves CLI / pre-existing client behavior. Default. + public static OptionsUpdateToolFilterPrecedence Available { get; } = new("available"); + + /// A tool is enabled if and only if 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. + public static OptionsUpdateToolFilterPrecedence Excluded { get; } = new("excluded"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(OptionsUpdateToolFilterPrecedence left, OptionsUpdateToolFilterPrecedence right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(OptionsUpdateToolFilterPrecedence left, OptionsUpdateToolFilterPrecedence right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is OptionsUpdateToolFilterPrecedence other && Equals(other); + + /// + public bool Equals(OptionsUpdateToolFilterPrecedence other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override OptionsUpdateToolFilterPrecedence Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, OptionsUpdateToolFilterPrecedence value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(OptionsUpdateToolFilterPrecedence)); + } + } +} + + /// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/). [Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -13464,6 +13531,7 @@ internal OptionsApi(CopilotSession session) /// Absolute working-directory path for shell tools. /// Allowlist of tool names available to this session. /// Denylist of tool names for this session. + /// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. /// Whether shell-script safety heuristics are enabled. /// Shell init profile (`None` or `NonInteractive`). /// Per-shell process flags (e.g., `pwsh` arguments). @@ -13491,11 +13559,11 @@ internal OptionsApi(CopilotSession session) /// Whether to expose the `manage_schedule` tool to the agent. The runtime always owns the per-session schedule registry; this flag only controls tool exposure (typically gated to staff users). /// The to monitor for cancellation requests. The default is . /// Indicates whether the session options patch was applied successfully. - public async Task UpdateAsync(string? model = null, string? reasoningEffort = null, string? clientName = null, string? lspClientName = null, string? integrationId = null, IDictionary? featureFlags = null, bool? isExperimentalMode = null, object? provider = null, string? workingDirectory = null, IList? availableTools = null, IList? excludedTools = null, bool? enableScriptSafety = null, string? shellInitProfile = null, IList? shellProcessFlags = null, object? sandboxConfig = null, bool? logInteractiveShells = null, OptionsUpdateEnvValueMode? envValueMode = null, IList? skillDirectories = null, IList? disabledSkills = null, bool? enableOnDemandInstructionDiscovery = null, IList? installedPlugins = null, bool? customAgentsLocalOnly = null, bool? skipCustomInstructions = null, IList? disabledInstructionSources = null, bool? coauthorEnabled = null, string? trajectoryFile = null, bool? enableStreaming = null, string? copilotUrl = null, bool? askUserDisabled = null, bool? continueOnAutoMode = null, bool? runningInInteractiveMode = null, bool? enableReasoningSummaries = null, string? agentContext = null, string? eventsLogDirectory = null, IList? additionalContentExclusionPolicies = null, bool? manageScheduleEnabled = null, CancellationToken cancellationToken = default) + public async Task UpdateAsync(string? model = null, string? reasoningEffort = null, string? clientName = null, string? lspClientName = null, string? integrationId = null, IDictionary? featureFlags = null, bool? isExperimentalMode = null, object? provider = null, string? workingDirectory = null, IList? availableTools = null, IList? excludedTools = null, OptionsUpdateToolFilterPrecedence? toolFilterPrecedence = null, bool? enableScriptSafety = null, string? shellInitProfile = null, IList? shellProcessFlags = null, object? sandboxConfig = null, bool? logInteractiveShells = null, OptionsUpdateEnvValueMode? envValueMode = null, IList? skillDirectories = null, IList? disabledSkills = null, bool? enableOnDemandInstructionDiscovery = null, IList? installedPlugins = null, bool? customAgentsLocalOnly = null, bool? skipCustomInstructions = null, IList? disabledInstructionSources = null, bool? coauthorEnabled = null, string? trajectoryFile = null, bool? enableStreaming = null, string? copilotUrl = null, bool? askUserDisabled = null, bool? continueOnAutoMode = null, bool? runningInInteractiveMode = null, bool? enableReasoningSummaries = null, string? agentContext = null, string? eventsLogDirectory = null, IList? additionalContentExclusionPolicies = null, bool? manageScheduleEnabled = null, CancellationToken cancellationToken = default) { _session.ThrowIfDisposed(); - var request = new SessionUpdateOptionsParams { SessionId = _session.SessionId, Model = model, ReasoningEffort = reasoningEffort, ClientName = clientName, LspClientName = lspClientName, IntegrationId = integrationId, FeatureFlags = featureFlags, IsExperimentalMode = isExperimentalMode, Provider = CopilotClient.ToJsonElementForWire(provider), WorkingDirectory = workingDirectory, AvailableTools = availableTools, ExcludedTools = excludedTools, EnableScriptSafety = enableScriptSafety, ShellInitProfile = shellInitProfile, ShellProcessFlags = shellProcessFlags, SandboxConfig = CopilotClient.ToJsonElementForWire(sandboxConfig), LogInteractiveShells = logInteractiveShells, EnvValueMode = envValueMode, SkillDirectories = skillDirectories, DisabledSkills = disabledSkills, EnableOnDemandInstructionDiscovery = enableOnDemandInstructionDiscovery, InstalledPlugins = installedPlugins, CustomAgentsLocalOnly = customAgentsLocalOnly, SkipCustomInstructions = skipCustomInstructions, DisabledInstructionSources = disabledInstructionSources, CoauthorEnabled = coauthorEnabled, TrajectoryFile = trajectoryFile, EnableStreaming = enableStreaming, CopilotUrl = copilotUrl, AskUserDisabled = askUserDisabled, ContinueOnAutoMode = continueOnAutoMode, RunningInInteractiveMode = runningInInteractiveMode, EnableReasoningSummaries = enableReasoningSummaries, AgentContext = agentContext, EventsLogDirectory = eventsLogDirectory, AdditionalContentExclusionPolicies = additionalContentExclusionPolicies?.Select(static v => CopilotClient.ToJsonElementForWire(v)!.Value).ToList(), ManageScheduleEnabled = manageScheduleEnabled }; + var request = new SessionUpdateOptionsParams { SessionId = _session.SessionId, Model = model, ReasoningEffort = reasoningEffort, ClientName = clientName, LspClientName = lspClientName, IntegrationId = integrationId, FeatureFlags = featureFlags, IsExperimentalMode = isExperimentalMode, Provider = CopilotClient.ToJsonElementForWire(provider), WorkingDirectory = workingDirectory, AvailableTools = availableTools, ExcludedTools = excludedTools, ToolFilterPrecedence = toolFilterPrecedence, EnableScriptSafety = enableScriptSafety, ShellInitProfile = shellInitProfile, ShellProcessFlags = shellProcessFlags, SandboxConfig = CopilotClient.ToJsonElementForWire(sandboxConfig), LogInteractiveShells = logInteractiveShells, EnvValueMode = envValueMode, SkillDirectories = skillDirectories, DisabledSkills = disabledSkills, EnableOnDemandInstructionDiscovery = enableOnDemandInstructionDiscovery, InstalledPlugins = installedPlugins, CustomAgentsLocalOnly = customAgentsLocalOnly, SkipCustomInstructions = skipCustomInstructions, DisabledInstructionSources = disabledInstructionSources, CoauthorEnabled = coauthorEnabled, TrajectoryFile = trajectoryFile, EnableStreaming = enableStreaming, CopilotUrl = copilotUrl, AskUserDisabled = askUserDisabled, ContinueOnAutoMode = continueOnAutoMode, RunningInInteractiveMode = runningInInteractiveMode, EnableReasoningSummaries = enableReasoningSummaries, AgentContext = agentContext, EventsLogDirectory = eventsLogDirectory, AdditionalContentExclusionPolicies = additionalContentExclusionPolicies?.Select(static v => CopilotClient.ToJsonElementForWire(v)!.Value).ToList(), ManageScheduleEnabled = manageScheduleEnabled }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.options.update", [request], cancellationToken); } } diff --git a/dotnet/src/ToolSet.cs b/dotnet/src/ToolSet.cs new file mode 100644 index 000000000..5045741b7 --- /dev/null +++ b/dotnet/src/ToolSet.cs @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.RegularExpressions; + +namespace GitHub.Copilot; + +/// +/// Builder for / +/// using source-qualified filter +/// patterns (builtin:*, mcp:<name>, custom:*, etc.). +/// +/// +/// +/// 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. +/// +/// +/// inherits from List<string>, so instances +/// can be assigned directly to +/// or . +/// +/// +/// +/// +/// var session = await client.CreateSessionAsync(new SessionConfig +/// { +/// AvailableTools = new ToolSet() +/// .AddBuiltIn(BuiltInTools.Isolated) +/// .AddMcp("*") +/// .AddCustom("*"), +/// }); +/// +/// +public sealed class ToolSet : List +{ + private static readonly Regex s_validToolName = new(@"^[a-zA-Z0-9_-]+$", RegexOptions.Compiled); + + /// + /// Adds one or more built-in tool patterns. + /// + /// A specific built-in tool name (e.g. "bash") or + /// "*" to match all built-in tools. + /// This for chaining. + public ToolSet AddBuiltIn(string name) + { + ValidateName("builtin", name); + Add($"builtin:{name}"); + return this; + } + + /// + /// Adds a list of built-in tool patterns + /// (e.g. ). + /// + /// Built-in tool names to add. + /// This for chaining. + public ToolSet AddBuiltIn(IEnumerable names) + { + ArgumentNullException.ThrowIfNull(names); + foreach (var name in names) + { + AddBuiltIn(name); + } + return this; + } + + /// + /// Adds a custom tool pattern. Matches tools registered via the SDK's + /// option or via custom agents. + /// + /// A specific custom tool name or "*" to match + /// all custom tools. + /// This for chaining. + public ToolSet AddCustom(string name) + { + ValidateName("custom", name); + Add($"custom:{name}"); + return this; + } + + /// + /// Adds an MCP tool pattern. Matches tools advertised by any configured + /// MCP server. + /// + /// The runtime's canonical wire name for the MCP + /// tool (e.g. "github-list_issues"), or "*" to match all + /// MCP tools from any server. + /// This for chaining. + public ToolSet AddMcp(string toolName) + { + ValidateName("mcp", toolName); + Add($"mcp:{toolName}"); + return this; + } + + private static void ValidateName(string kind, string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException( + $"Invalid {kind} tool name: must not be null or empty.", + nameof(name)); + } + if (name == "*") + { + return; + } + if (!s_validToolName.IsMatch(name)) + { + throw new ArgumentException( + $"Invalid {kind} tool name '{name}': tool names must match /^[a-zA-Z0-9_-]+$/ " + + "or be the wildcard '*'.", + nameof(name)); + } + } +} + +/// +/// Curated sets of built-in tool names for common scenarios. Each constant is +/// meant to be passed to . +/// +public static class 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 + /// 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. + /// + /// + public static IReadOnlyList Isolated { get; } = + [ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", + ]; +} diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index e46a7a888..d05316215 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -206,6 +206,48 @@ internal UriRuntimeConnection() { } public string? ConnectionToken { get; set; } } +/// +/// Selects the defaulting strategy used by . +/// +public enum CopilotClientMode +{ + /// + /// 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). + /// + /// When this mode is selected: + /// + /// + /// The client constructor requires + /// or + /// to be set. + /// must be supplied on + /// every session — no tools are exposed by default. + /// session.create always sets + /// toolFilterPrecedence: "excluded" so the allowlist and denylist + /// compose naturally. + /// The SDK injects safe defaults for ambient session features + /// (telemetry, custom instructions, plugins, environment context, etc.). + /// COPILOT_DISABLE_KEYTAR=1 is set on the spawned runtime so + /// credentials are persisted to COPILOT_HOME rather than a + /// process-wide system keychain. + /// + /// + Empty, + + /// + /// Uses defaults equivalent to GitHub Copilot CLI. The default. 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. + /// + /// + CopilotCli, +} + /// /// Configuration options for creating a instance. /// @@ -237,8 +279,23 @@ private CopilotClientOptions(CopilotClientOptions? other) SessionFs = other.SessionFs; SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds; EnableRemoteSessions = other.EnableRemoteSessions; + Mode = other.Mode; } + /// + /// Selects the SDK defaulting strategy. See . + /// + /// + /// When set to , the SDK validates that + /// the app has supplied the required configuration + /// ( or , plus + /// on each session) and + /// translates session creation requests into runtime options that flip + /// tool filter precedence to excluded-wins so exclusions are + /// expressible. + /// + public CopilotClientMode Mode { get; set; } = CopilotClientMode.CopilotCli; + /// /// How to connect to the runtime. When null, the default is /// with the bundled runtime. @@ -2306,6 +2363,10 @@ protected SessionConfigBase(SessionConfigBase? other) OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; EnableSessionTelemetry = other.EnableSessionTelemetry; + SkipCustomInstructions = other.SkipCustomInstructions; + CustomAgentsLocalOnly = other.CustomAgentsLocalOnly; + CoauthorEnabled = other.CoauthorEnabled; + ManageScheduleEnabled = other.ManageScheduleEnabled; ReasoningEffort = other.ReasoningEffort; CreateSessionFsProvider = other.CreateSessionFsProvider; GitHubToken = other.GitHubToken; @@ -2391,6 +2452,42 @@ protected SessionConfigBase(SessionConfigBase? other) /// public bool? EnableSessionTelemetry { get; set; } + /// + /// When , suppresses loading of custom instruction files + /// (e.g. .github/copilot-instructions.md, AGENTS.md) from the working directory. + /// When , the SDK chooses based on + /// : true under + /// (instructions are not loaded + /// unless the app explicitly opts in), null otherwise. + /// + public bool? SkipCustomInstructions { get; set; } + + /// + /// When , custom-agent discovery is restricted to the + /// session's local working directory (no organisation-level discovery). + /// When , the SDK chooses based on + /// : true under + /// , null otherwise. + /// + public bool? CustomAgentsLocalOnly { get; set; } + + /// + /// When , allows the runtime to append a + /// Co-authored-by trailer when it commits on behalf of the user. + /// When , the SDK chooses based on + /// : false under + /// , null otherwise. + /// + public bool? CoauthorEnabled { get; set; } + + /// + /// When , enables the manage_schedule tool + /// (host scheduler integration). When , the SDK + /// chooses based on : false + /// under , null otherwise. + /// + public bool? ManageScheduleEnabled { get; set; } + /// Handler for permission requests from the server. public Func>? OnPermissionRequest { get; set; } diff --git a/dotnet/test/E2E/ModeEmptyE2ETests.cs b/dotnet/test/E2E/ModeEmptyE2ETests.cs new file mode 100644 index 000000000..7ac19485f --- /dev/null +++ b/dotnet/test/E2E/ModeEmptyE2ETests.cs @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E coverage for the Mode = CopilotClientMode.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 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). +/// +/// Mirrors nodejs/test/e2e/mode_empty.e2e.test.ts and shares the same +/// recorded cassettes under test/snapshots/mode_empty/. +/// +/// Test method names are intentionally lowercase + underscore so that +/// sanitizes them to the +/// same filenames the Node tests produce. +/// +public class ModeEmptyE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "mode_empty", output) +{ + private CopilotClient CreateEmptyModeClient() + { + return Ctx.CreateClient(options: new CopilotClientOptions + { + Mode = CopilotClientMode.Empty, + BaseDirectory = Ctx.HomeDir, + }); + } + + [Fact] + public async Task Empty_Mode_Isolated_Set_Shell_Tool_Is_Not_Exposed() + { + var client = CreateEmptyModeClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + }); + + try + { + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); + } + catch + { + // Some runs end the turn without producing a final assistant message; + // we only care about the tool surface the LLM was shown. + } + + var exchanges = await WaitForExchangesAsync(); + var toolNames = GetToolNames(exchanges[^1]); + + Assert.DoesNotContain("bash", toolNames); + Assert.DoesNotContain("edit", toolNames); + Assert.DoesNotContain("grep", toolNames); + Assert.DoesNotContain("web_fetch", toolNames); + + // Sanity: at least one of the isolated tools is registered. + Assert.Contains(toolNames, name => BuiltInTools.Isolated.Contains(name)); + } + + [Fact] + public async Task Empty_Mode_Builtin_Star_Exposes_All_Built_In_Tools() + { + var client = CreateEmptyModeClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn("*"), + }); + + try + { + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); + } + catch + { + } + + var exchanges = await WaitForExchangesAsync(); + var toolNames = GetToolNames(exchanges[^1]); + + // 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. + var shellToolName = OperatingSystem.IsWindows() ? "powershell" : "bash"; + Assert.Contains(shellToolName, toolNames); + } + + [Fact] + public async Task Empty_Mode_Excluded_Tools_Subtracts_From_Available_Tools() + { + var shellToolName = OperatingSystem.IsWindows() ? "powershell" : "bash"; + var client = CreateEmptyModeClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn("*"), + ExcludedTools = [$"builtin:{shellToolName}"], + }); + + try + { + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); + } + catch + { + } + + var exchanges = await WaitForExchangesAsync(); + var toolNames = GetToolNames(exchanges[^1]); + + // The platform shell is in builtin:* but explicitly excluded → must not be exposed. + Assert.DoesNotContain(shellToolName, toolNames); + // Other built-ins are still there (proves the subtraction is targeted). + Assert.NotEmpty(toolNames); + } + + [Fact] + public async Task Empty_Mode_Strips_Environment_Context_From_The_System_Message_By_Default() + { + // We can't directly observe section presence, but we can detect it + // indirectly: in default empty mode the SDK injects the customize-mode + // override environment_context: { action: "remove" }. We also append a + // deterministic instruction. If the env_context strip didn't fire, the + // runtime would still inject OS/cwd lines into the system message. + var client = CreateEmptyModeClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Content = "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + }, + }); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Name an element." }); + Assert.Contains("ARGON", reply?.Data.Content ?? string.Empty); + + var exchanges = await WaitForExchangesAsync(); + var systemMessage = GetSystemMessage(exchanges[^1]); + Assert.DoesNotMatch(@"(?i)Current working directory:", systemMessage); + Assert.DoesNotMatch(@"(?i)Operating System:", systemMessage); + } + + [Fact] + public async Task Empty_Mode_System_Message_Replace_Llm_Follows_Caller_Content_Verbatim() + { + var client = CreateEmptyModeClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + }, + }); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello." }); + Assert.Contains("KRYPTON", reply?.Data.Content ?? string.Empty); + } + + [Fact] + public async Task Empty_Mode_Append_Caller_Instruction_Takes_Effect_And_Env_Context_Stripped() + { + var client = CreateEmptyModeClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + }, + }); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Name a noble gas." }); + Assert.Contains("XENON", reply?.Data.Content ?? string.Empty); + + var exchanges = await WaitForExchangesAsync(); + var systemMessage = GetSystemMessage(exchanges[^1]); + Assert.DoesNotMatch(@"(?i)Current working directory:", systemMessage); + Assert.DoesNotMatch(@"(?i)Operating System:", systemMessage); + } +} diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 9ba283cfd..7c39dadc2 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -4900,6 +4900,9 @@ type SessionUpdateOptionsParams struct { SkillDirectories []string `json:"skillDirectories,omitempty"` // Whether to skip loading custom instruction sources. SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` + // Controls how availableTools (allowlist) and excludedTools (denylist) combine when both + // are set. + ToolFilterPrecedence *OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` // Optional path for trajectory output. TrajectoryFile *string `json:"trajectoryFile,omitempty"` // Absolute working-directory path for shell tools. @@ -6940,6 +6943,22 @@ const ( OptionsUpdateEnvValueModeIndirect OptionsUpdateEnvValueMode = "indirect" ) +// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both +// are set. +// Experimental: OptionsUpdateToolFilterPrecedence is part of an experimental API and may +// change or be removed. +type OptionsUpdateToolFilterPrecedence string + +const ( + // If availableTools is set, it is the only constraint that applies (excludedTools is + // ignored). Preserves CLI / pre-existing client behavior. Default. + OptionsUpdateToolFilterPrecedenceAvailable OptionsUpdateToolFilterPrecedence = "available" + // A tool is enabled if and only if 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. + OptionsUpdateToolFilterPrecedenceExcluded OptionsUpdateToolFilterPrecedence = "excluded" +) + // Kind discriminator for PermissionDecisionApproveForLocationApproval. type PermissionDecisionApproveForLocationApprovalKind string @@ -9985,6 +10004,9 @@ func (a *OptionsApi) Update(ctx context.Context, params *SessionUpdateOptionsPar if params.SkipCustomInstructions != nil { req["skipCustomInstructions"] = *params.SkipCustomInstructions } + if params.ToolFilterPrecedence != nil { + req["toolFilterPrecedence"] = *params.ToolFilterPrecedence + } if params.TrajectoryFile != nil { req["trajectoryFile"] = *params.TrajectoryFile } diff --git a/nodejs/test/e2e/mode_empty.e2e.test.ts b/nodejs/test/e2e/mode_empty.e2e.test.ts index e39506a40..a3c22bdfe 100644 --- a/nodejs/test/e2e/mode_empty.e2e.test.ts +++ b/nodejs/test/e2e/mode_empty.e2e.test.ts @@ -66,7 +66,7 @@ describe("Mode = empty + ToolSet patterns", async () => { return ""; } - it("empty mode + Isolated set: shell tool is NOT exposed", async () => { + it("empty mode isolated set shell tool is not exposed", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), @@ -86,7 +86,7 @@ describe("Mode = empty + ToolSet patterns", async () => { await session.disconnect(); }); - it("empty mode + builtin:* exposes all built-in tools", async () => { + it("empty mode builtin star exposes all built in tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, availableTools: new ToolSet().addBuiltIn("*"), @@ -103,7 +103,7 @@ describe("Mode = empty + ToolSet patterns", async () => { await session.disconnect(); }); - it("empty mode + excluded default: excludedTools subtracts from availableTools", async () => { + it("empty mode excluded tools subtracts from available tools", async () => { const shellToolName = process.platform === "win32" ? "powershell" : "bash"; const session = await client.createSession({ onPermissionRequest: approveAll, @@ -148,7 +148,7 @@ describe("Mode = empty + ToolSet patterns", async () => { await session.disconnect(); }); - it("empty mode + systemMessage replace: LLM follows caller's content verbatim", async () => { + it("empty mode system message replace llm follows caller content verbatim", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), @@ -164,7 +164,7 @@ describe("Mode = empty + ToolSet patterns", async () => { await session.disconnect(); }); - it("empty mode + append: caller's instruction takes effect and env_context is still stripped", async () => { + it("empty mode append caller instruction takes effect and env context stripped", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index e18f83f8b..01cbae4e1 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -2719,6 +2719,14 @@ def to_dict(self) -> dict: result["name"] = from_str(self.name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +class OptionsUpdateToolFilterPrecedence(Enum): + """Controls how availableTools (allowlist) and excludedTools (denylist) combine when both + are set. + """ + AVAILABLE = "available" + EXCLUDED = "excluded" + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class PendingPermissionRequest: @@ -14299,6 +14307,10 @@ class SessionUpdateOptionsParams: skip_custom_instructions: bool | None = None """Whether to skip loading custom instruction sources.""" + tool_filter_precedence: OptionsUpdateToolFilterPrecedence | None = None + """Controls how availableTools (allowlist) and excludedTools (denylist) combine when both + are set. + """ trajectory_file: str | None = None """Optional path for trajectory output.""" @@ -14342,9 +14354,10 @@ def from_dict(obj: Any) -> 'SessionUpdateOptionsParams': shell_process_flags = from_union([lambda x: from_list(from_str, x), from_none], obj.get("shellProcessFlags")) skill_directories = from_union([lambda x: from_list(from_str, x), from_none], obj.get("skillDirectories")) skip_custom_instructions = from_union([from_bool, from_none], obj.get("skipCustomInstructions")) + tool_filter_precedence = from_union([OptionsUpdateToolFilterPrecedence, from_none], obj.get("toolFilterPrecedence")) trajectory_file = from_union([from_str, from_none], obj.get("trajectoryFile")) working_directory = from_union([from_str, from_none], obj.get("workingDirectory")) - return SessionUpdateOptionsParams(additional_content_exclusion_policies, agent_context, ask_user_disabled, available_tools, client_name, coauthor_enabled, continue_on_auto_mode, copilot_url, custom_agents_local_only, disabled_instruction_sources, disabled_skills, enable_on_demand_instruction_discovery, enable_reasoning_summaries, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, excluded_tools, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, manage_schedule_enabled, model, provider, reasoning_effort, running_in_interactive_mode, sandbox_config, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, working_directory) + return SessionUpdateOptionsParams(additional_content_exclusion_policies, agent_context, ask_user_disabled, available_tools, client_name, coauthor_enabled, continue_on_auto_mode, copilot_url, custom_agents_local_only, disabled_instruction_sources, disabled_skills, enable_on_demand_instruction_discovery, enable_reasoning_summaries, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, excluded_tools, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, manage_schedule_enabled, model, provider, reasoning_effort, running_in_interactive_mode, sandbox_config, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, tool_filter_precedence, trajectory_file, working_directory) def to_dict(self) -> dict: result: dict = {} @@ -14416,6 +14429,8 @@ def to_dict(self) -> dict: result["skillDirectories"] = from_union([lambda x: from_list(from_str, x), from_none], self.skill_directories) if self.skip_custom_instructions is not None: result["skipCustomInstructions"] = from_union([from_bool, from_none], self.skip_custom_instructions) + if self.tool_filter_precedence is not None: + result["toolFilterPrecedence"] = from_union([lambda x: to_enum(OptionsUpdateToolFilterPrecedence, x), from_none], self.tool_filter_precedence) if self.trajectory_file is not None: result["trajectoryFile"] = from_union([from_str, from_none], self.trajectory_file) if self.working_directory is not None: @@ -15057,6 +15072,7 @@ class RPC: name_set_request: NameSetRequest open_canvas_instance: OpenCanvasInstance options_update_env_value_mode: MCPSetEnvValueModeDetails + options_update_tool_filter_precedence: OptionsUpdateToolFilterPrecedence pending_permission_request: PendingPermissionRequest pending_permission_request_list: PendingPermissionRequestList permission_decision: PermissionDecision @@ -15617,6 +15633,7 @@ def from_dict(obj: Any) -> 'RPC': name_set_request = NameSetRequest.from_dict(obj.get("NameSetRequest")) open_canvas_instance = OpenCanvasInstance.from_dict(obj.get("OpenCanvasInstance")) options_update_env_value_mode = MCPSetEnvValueModeDetails(obj.get("OptionsUpdateEnvValueMode")) + options_update_tool_filter_precedence = OptionsUpdateToolFilterPrecedence(obj.get("OptionsUpdateToolFilterPrecedence")) pending_permission_request = PendingPermissionRequest.from_dict(obj.get("PendingPermissionRequest")) pending_permission_request_list = PendingPermissionRequestList.from_dict(obj.get("PendingPermissionRequestList")) permission_decision = _load_PermissionDecision(obj.get("PermissionDecision")) @@ -15958,7 +15975,7 @@ def from_dict(obj: Any) -> 'RPC': session_context_info = from_union([SessionContextInfo.from_dict, from_none], obj.get("SessionContextInfo")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_reload_result, agent_select_request, agent_select_result, api_key_auth_info, auth_info, auth_info_type, canvas_action, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_instance_availability, canvas_invoke_action_request, canvas_invoke_action_result, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_source, installed_plugin_source_github, installed_plugin_source_local, installed_plugin_source_url, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, lsp_initialize_request, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_oauth_login_request, mcp_oauth_login_result, mcp_remove_git_hub_result, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_auth, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_oidc, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_config_stdio_auth, mcp_server_config_stdio_oidc, mcp_server_list, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_env_value_mode, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, release_event_interest_params, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_mode, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachment, send_attachment_blob, send_attachment_directory, send_attachment_file, send_attachment_file_line_range, send_attachment_github_reference, send_attachment_github_reference_type, send_attachment_selection, send_attachment_selection_details, send_attachment_selection_details_end, send_attachment_selection_details_start, send_mode, send_request, send_result, server_skill, server_skill_list, session_auth_status, session_bulk_delete_result, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_github, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata, session_metadata_snapshot, session_mode, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_prune_old_request, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_initialize_and_validate_result, tools_list_request, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_reload_result, agent_select_request, agent_select_result, api_key_auth_info, auth_info, auth_info_type, canvas_action, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_instance_availability, canvas_invoke_action_request, canvas_invoke_action_result, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_source, installed_plugin_source_github, installed_plugin_source_local, installed_plugin_source_url, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, lsp_initialize_request, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_oauth_login_request, mcp_oauth_login_result, mcp_remove_git_hub_result, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_auth, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_oidc, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_config_stdio_auth, mcp_server_config_stdio_oidc, mcp_server_list, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_env_value_mode, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, release_event_interest_params, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_mode, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachment, send_attachment_blob, send_attachment_directory, send_attachment_file, send_attachment_file_line_range, send_attachment_github_reference, send_attachment_github_reference_type, send_attachment_selection, send_attachment_selection_details, send_attachment_selection_details_end, send_attachment_selection_details_start, send_mode, send_request, send_result, server_skill, server_skill_list, session_auth_status, session_bulk_delete_result, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_github, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata, session_metadata_snapshot, session_mode, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_prune_old_request, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_initialize_and_validate_result, tools_list_request, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -16177,6 +16194,7 @@ def to_dict(self) -> dict: result["NameSetRequest"] = to_class(NameSetRequest, self.name_set_request) result["OpenCanvasInstance"] = to_class(OpenCanvasInstance, self.open_canvas_instance) result["OptionsUpdateEnvValueMode"] = to_enum(MCPSetEnvValueModeDetails, self.options_update_env_value_mode) + result["OptionsUpdateToolFilterPrecedence"] = to_enum(OptionsUpdateToolFilterPrecedence, self.options_update_tool_filter_precedence) result["PendingPermissionRequest"] = to_class(PendingPermissionRequest, self.pending_permission_request) result["PendingPermissionRequestList"] = to_class(PendingPermissionRequestList, self.pending_permission_request_list) result["PermissionDecision"] = (self.permission_decision).to_dict() diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 58451ded9..5629bfc98 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -10,7 +10,8 @@ use super::session_events::{ AbortReason, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval, }; -use crate::types::{RequestId, SessionEvent, SessionId}; +use crate::types::SessionEvent; +use crate::types::{RequestId, SessionId}; /// JSON-RPC method name constants. pub mod rpc_methods { @@ -7331,6 +7332,9 @@ pub struct SessionUpdateOptionsParams { /// Whether to skip loading custom instruction sources. #[serde(skip_serializing_if = "Option::is_none")] pub skip_custom_instructions: Option, + /// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_filter_precedence: Option, /// Optional path for trajectory output. #[serde(skip_serializing_if = "Option::is_none")] pub trajectory_file: Option, @@ -13135,6 +13139,28 @@ pub enum OptionsUpdateEnvValueMode { Unknown, } +/// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum OptionsUpdateToolFilterPrecedence { + /// If availableTools is set, it is the only constraint that applies (excludedTools is ignored). Preserves CLI / pre-existing client behavior. Default. + #[serde(rename = "available")] + Available, + /// A tool is enabled if and only if 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. + #[serde(rename = "excluded")] + Excluded, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Approve this single request only #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum PermissionDecisionApproveOnceKind { diff --git a/test/snapshots/mode_empty/empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml b/test/snapshots/mode_empty/empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml similarity index 100% rename from test/snapshots/mode_empty/empty_mode___append__caller_s_instruction_takes_effect_and_env_context_is_still_stripped.yaml rename to test/snapshots/mode_empty/empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml diff --git a/test/snapshots/mode_empty/empty_mode___builtin___exposes_all_built_in_tools.yaml b/test/snapshots/mode_empty/empty_mode_builtin_star_exposes_all_built_in_tools.yaml similarity index 100% rename from test/snapshots/mode_empty/empty_mode___builtin___exposes_all_built_in_tools.yaml rename to test/snapshots/mode_empty/empty_mode_builtin_star_exposes_all_built_in_tools.yaml diff --git a/test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml b/test/snapshots/mode_empty/empty_mode_excluded_tools_subtracts_from_available_tools.yaml similarity index 100% rename from test/snapshots/mode_empty/empty_mode___excluded_default__excludedtools_subtracts_from_availabletools.yaml rename to test/snapshots/mode_empty/empty_mode_excluded_tools_subtracts_from_available_tools.yaml 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 similarity index 100% rename from test/snapshots/mode_empty/empty_mode___isolated_set__shell_tool_is_not_exposed.yaml rename to test/snapshots/mode_empty/empty_mode_isolated_set_shell_tool_is_not_exposed.yaml diff --git a/test/snapshots/mode_empty/empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml b/test/snapshots/mode_empty/empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml similarity index 100% rename from test/snapshots/mode_empty/empty_mode___systemmessage_replace__llm_follows_caller_s_content_verbatim.yaml rename to test/snapshots/mode_empty/empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml From 720c438f9c86866ebc7c73dec603189fe4878aa7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 15:32:29 +0100 Subject: [PATCH 08/11] [Go] Mode=Empty SDK surface and ToolSet builder Mirrors the C#/Node Mode=Empty implementation: - ClientMode (ModeEmpty/ModeCopilotCli) + Mode field on ClientOptions - ToolSet builder with AddBuiltIn/AddMcp/AddCustom + BuiltInToolsIsolated - Tool-name charset validation (panics on /[^a-zA-Z0-9_-]/) - NewClient validation: empty mode requires BaseDirectory, SessionFs, or UriConnection - 4 opt-back-in fields on SessionConfig and ResumeSessionConfig (SkipCustomInstructions, CustomAgentsLocalOnly, CoauthorEnabled, ManageScheduleEnabled) - ToolFilterPrecedence on createSessionRequest/resumeSessionRequest - Mode helpers: resolveToolFilterOptions, systemMessageForMode (strips environment_context), applyConfigDefaultsForMode (telemetry off), updateSessionOptionsForMode (post-create patch) - COPILOT_DISABLE_KEYTAR=1 in spawned runtime env when Mode=ModeEmpty - toolset_test.go unit tests (16/16 passing) - mode_empty_e2e_test.go reuses the shared test/snapshots/mode_empty/ cassettes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 70 ++++++- go/internal/e2e/mode_empty_e2e_test.go | 239 ++++++++++++++++++++++++ go/mode_empty.go | 207 ++++++++++++++++++++ go/toolset.go | 122 ++++++++++++ go/toolset_test.go | 249 +++++++++++++++++++++++++ go/types.go | 45 +++++ 6 files changed, 926 insertions(+), 6 deletions(-) create mode 100644 go/internal/e2e/mode_empty_e2e_test.go create mode 100644 go/mode_empty.go create mode 100644 go/toolset.go create mode 100644 go/toolset_test.go diff --git a/go/client.go b/go/client.go index ae89128a1..24a936f09 100644 --- a/go/client.go +++ b/go/client.go @@ -237,6 +237,7 @@ func NewClient(options *ClientOptions) *Client { } client.options = opts + validateNewClientForMode(&client.options) return client } @@ -597,6 +598,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses return nil, err } + c.applyConfigDefaultsForMode(config) + req := createSessionRequest{} req.Model = config.Model req.ClientName = config.ClientName @@ -606,12 +609,22 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.EnableConfigDiscovery = Bool(true) } req.Tools = config.Tools - wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + systemMessage := c.systemMessageForMode(config.SystemMessage) + wireSystemMessage, transformCallbacks := extractTransformCallbacks(systemMessage) req.SystemMessage = wireSystemMessage - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + availableTools, excludedTools, precedence, ferr := c.resolveToolFilterOptions(config.AvailableTools, config.ExcludedTools) + if ferr != nil { + return nil, ferr + } + req.AvailableTools = availableTools + req.ExcludedTools = excludedTools + req.ToolFilterPrecedence = precedence req.Provider = config.Provider req.EnableSessionTelemetry = config.EnableSessionTelemetry + req.SkipCustomInstructions = config.SkipCustomInstructions + req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly + req.CoauthorEnabled = config.CoauthorEnabled + req.ManageScheduleEnabled = config.ManageScheduleEnabled req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -758,6 +771,22 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) + if err := c.updateSessionOptionsForMode(ctx, session, optBackInFields{ + SkipCustomInstructions: config.SkipCustomInstructions, + CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, + CoauthorEnabled: config.CoauthorEnabled, + ManageScheduleEnabled: config.ManageScheduleEnabled, + }); err != nil { + // In empty mode, refuse to expose a session whose safe-defaults + // patch was rejected: tear it down so callers never get a + // permissive session. + _ = session.Disconnect() + c.sessionsMux.Lock() + delete(c.sessions, sessionID) + c.sessionsMux.Unlock() + return nil, fmt.Errorf("failed to apply mode-specific session options: %w", err) + } + return session, nil } @@ -793,19 +822,31 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, return nil, err } + c.applyResumeDefaultsForMode(config) + var req resumeSessionRequest req.SessionID = sessionID req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort - wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + systemMessage := c.systemMessageForMode(config.SystemMessage) + wireSystemMessage, transformCallbacks := extractTransformCallbacks(systemMessage) req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider req.EnableSessionTelemetry = config.EnableSessionTelemetry + req.SkipCustomInstructions = config.SkipCustomInstructions + req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly + req.CoauthorEnabled = config.CoauthorEnabled + req.ManageScheduleEnabled = config.ManageScheduleEnabled req.ModelCapabilities = config.ModelCapabilities - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + availableTools, excludedTools, precedence, ferr := c.resolveToolFilterOptions(config.AvailableTools, config.ExcludedTools) + if ferr != nil { + return nil, ferr + } + req.AvailableTools = availableTools + req.ExcludedTools = excludedTools + req.ToolFilterPrecedence = precedence if config.Streaming != nil { req.Streaming = config.Streaming } @@ -955,6 +996,19 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.setCapabilities(response.Capabilities) session.setOpenCanvases(response.OpenCanvases) + if err := c.updateSessionOptionsForMode(ctx, session, optBackInFields{ + SkipCustomInstructions: config.SkipCustomInstructions, + CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, + CoauthorEnabled: config.CoauthorEnabled, + ManageScheduleEnabled: config.ManageScheduleEnabled, + }); err != nil { + _ = session.Disconnect() + c.sessionsMux.Lock() + delete(c.sessions, sessionID) + c.sessionsMux.Unlock() + return nil, fmt.Errorf("failed to apply mode-specific session options: %w", err) + } + return session, nil } @@ -1536,6 +1590,10 @@ func (c *Client) startCLIServer(ctx context.Context) error { c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.BaseDirectory) } + if c.options.Mode == ModeEmpty { + c.process.Env = setEnvValue(c.process.Env, "COPILOT_DISABLE_KEYTAR", "1") + } + if c.options.Telemetry != nil { t := c.options.Telemetry c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_ENABLED", "true") diff --git a/go/internal/e2e/mode_empty_e2e_test.go b/go/internal/e2e/mode_empty_e2e_test.go new file mode 100644 index 000000000..86e1246c6 --- /dev/null +++ b/go/internal/e2e/mode_empty_e2e_test.go @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package e2e + +import ( + "context" + "regexp" + "runtime" + "slices" + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +// E2E coverage for Mode = ModeEmpty + ToolSet patterns. The runtime is +// mode-agnostic — these tests verify the SDK's translation reaches the +// runtime by inspecting captured chat-completion requests via the proxy. +func TestModeEmptyE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient(func(o *copilot.ClientOptions) { + o.Mode = copilot.ModeEmpty + o.BaseDirectory = ctx.HomeDir + }) + t.Cleanup(func() { client.ForceStop() }) + + getToolsExposedToLLM := func(t *testing.T) []string { + t.Helper() + exchanges := ctx.WaitForExchanges(t, 1) + last := exchanges[len(exchanges)-1] + names := make([]string, 0, len(last.Request.Tools)) + for _, tool := range last.Request.Tools { + if tool.Type == "function" && tool.Function.Name != "" { + names = append(names, tool.Function.Name) + } + } + return names + } + + getSystemMessageSentToLLM := func(t *testing.T) string { + t.Helper() + exchanges := ctx.WaitForExchanges(t, 1) + last := exchanges[len(exchanges)-1] + for _, m := range last.Request.Messages { + if m.Role == "system" { + return m.Content + } + } + return "" + } + + shellToolName := "bash" + if runtime.GOOS == "windows" { + shellToolName = "powershell" + } + + t.Run("empty mode isolated set shell tool is not exposed", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + _, _ = session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Say hi."}) + + toolNames := getToolsExposedToLLM(t) + for _, banned := range []string{"bash", "powershell", "edit", "grep", "web_fetch"} { + if slices.Contains(toolNames, banned) { + t.Errorf("isolated set must not expose %q, got tools %v", banned, toolNames) + } + } + anyIsolated := false + for _, name := range copilot.BuiltInToolsIsolated { + if slices.Contains(toolNames, name) { + anyIsolated = true + break + } + } + if !anyIsolated { + t.Errorf("expected at least one isolated tool to be registered, got %v", toolNames) + } + }) + + t.Run("empty mode builtin star exposes all built in tools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn("*").ToSlice(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + _, _ = session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Say hi."}) + + toolNames := getToolsExposedToLLM(t) + if !slices.Contains(toolNames, shellToolName) { + t.Errorf("builtin:* should expose %q, got %v", shellToolName, toolNames) + } + }) + + t.Run("empty mode excluded tools subtracts from available tools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn("*").ToSlice(), + ExcludedTools: []string{"builtin:" + shellToolName}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + _, _ = session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Say hi."}) + + toolNames := getToolsExposedToLLM(t) + if slices.Contains(toolNames, shellToolName) { + t.Errorf("excluded shell tool %q leaked through builtin:*, got %v", shellToolName, toolNames) + } + if len(toolNames) == 0 { + t.Errorf("expected other built-ins to remain after subtraction, got empty list") + } + }) + + t.Run("empty mode strips environment context from the system message by default", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Content: "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + reply, err := session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Name an element."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if data, ok := reply.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(data.Content, "ARGON") { + t.Errorf("expected response to contain ARGON, got %+v", reply.Data) + } + + sys := getSystemMessageSentToLLM(t) + if regexp.MustCompile(`(?i)current working directory:`).MatchString(sys) { + t.Errorf("system message should not contain 'Current working directory:': %q", sys) + } + if regexp.MustCompile(`(?i)operating system:`).MatchString(sys) { + t.Errorf("system message should not contain 'Operating System:': %q", sys) + } + }) + + t.Run("empty mode system message replace llm follows caller content verbatim", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + reply, err := session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Hello."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if data, ok := reply.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(data.Content, "KRYPTON") { + t.Errorf("expected response to contain KRYPTON, got %+v", reply.Data) + } + }) + + t.Run("empty mode append caller instruction takes effect and env context stripped", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "append", + Content: "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + reply, err := session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Name a noble gas."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if data, ok := reply.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(data.Content, "XENON") { + t.Errorf("expected response to contain XENON, got %+v", reply.Data) + } + + sys := getSystemMessageSentToLLM(t) + if regexp.MustCompile(`(?i)current working directory:`).MatchString(sys) { + t.Errorf("system message should not contain 'Current working directory:': %q", sys) + } + if regexp.MustCompile(`(?i)operating system:`).MatchString(sys) { + t.Errorf("system message should not contain 'Operating System:': %q", sys) + } + }) +} diff --git a/go/mode_empty.go b/go/mode_empty.go new file mode 100644 index 000000000..417cfa2ad --- /dev/null +++ b/go/mode_empty.go @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package copilot + +import ( + "context" + "errors" + "fmt" + + "github.com/github/copilot-sdk/go/rpc" +) + +// validateNewClientForMode checks the cross-cutting requirements that +// [ModeEmpty] places on [ClientOptions]. Called from [NewClient]. +func validateNewClientForMode(opts *ClientOptions) { + if opts == nil || opts.Mode != ModeEmpty { + return + } + // Empty mode requires durable, app-owned storage. Either: + // - the app supplied a BaseDirectory the runtime can write to, + // - the app supplied a SessionFs implementation, + // - or the app is connecting to an externally-managed runtime via + // UriConnection (in which case the host owns storage). + if opts.BaseDirectory != "" { + return + } + if opts.SessionFs != nil { + return + } + if _, ok := opts.Connection.(UriConnection); ok { + return + } + panic("Client is in Mode=ModeEmpty but neither BaseDirectory, SessionFs, nor a UriConnection was supplied. " + + "Empty mode requires explicit, per-tenant storage; set ClientOptions.BaseDirectory or .SessionFs, " + + "or connect to an externally-managed runtime via UriConnection.") +} + +// validateToolFilterList rejects bare "*" entries with an actionable error +// pointing at the [ToolSet] builder. Called for both availableTools and +// excludedTools. +func validateToolFilterList(field string, list []string) error { + for _, entry := range list { + if entry == "*" { + return fmt.Errorf( + "invalid %s entry %q: there is no bare wildcard. "+ + "Use one or more of NewToolSet().AddBuiltIn(\"*\"), .AddMcp(\"*\"), or .AddCustom(\"*\") "+ + "to target a specific source", + field, entry) + } + } + return nil +} + +// resolveToolFilterOptions validates the configured tool filters and applies +// empty-mode invariants. Returns the (possibly-mutated) request fields to set. +func (c *Client) resolveToolFilterOptions(availableTools, excludedTools []string) ( + []string, []string, *rpc.OptionsUpdateToolFilterPrecedence, error, +) { + if err := validateToolFilterList("availableTools", availableTools); err != nil { + return nil, nil, nil, err + } + if err := validateToolFilterList("excludedTools", excludedTools); err != nil { + return nil, nil, nil, err + } + if c.options.Mode == ModeEmpty && availableTools == nil { + return nil, nil, nil, errors.New( + "Client is in Mode=ModeEmpty but the session config did not specify AvailableTools. " + + "Empty mode requires every session to explicitly opt into the tools it wants — " + + "e.g. NewToolSet().AddBuiltIn(BuiltInToolsIsolated...).ToSlice()") + } + precedence := rpc.OptionsUpdateToolFilterPrecedenceExcluded + return availableTools, excludedTools, &precedence, nil +} + +// systemMessageForMode applies empty-mode environment_context stripping to +// the caller-supplied system message config. App values win (we only inject +// when the app hasn't already specified an environment_context override). +func (c *Client) systemMessageForMode(supplied *SystemMessageConfig) *SystemMessageConfig { + if c.options.Mode != ModeEmpty { + return supplied + } + removeAction := SectionOverride{Action: SectionActionRemove} + if supplied == nil { + return &SystemMessageConfig{ + Mode: "customize", + Sections: map[string]SectionOverride{"environment_context": removeAction}, + } + } + switch supplied.Mode { + case "replace": + return supplied + case "customize": + if _, ok := supplied.Sections["environment_context"]; ok { + return supplied + } + out := *supplied + out.Sections = make(map[string]SectionOverride, len(supplied.Sections)+1) + for k, v := range supplied.Sections { + out.Sections[k] = v + } + out.Sections["environment_context"] = removeAction + return &out + case "append", "": + // Promote append/unspecified to customize so we can also strip + // environment_context. The runtime appends Content to additional + // instructions in both modes, so caller text is preserved verbatim. + return &SystemMessageConfig{ + Mode: "customize", + Content: supplied.Content, + Sections: map[string]SectionOverride{"environment_context": removeAction}, + } + default: + return supplied + } +} + +// applyConfigDefaultsForMode fills in empty-mode defaults on the session +// config in place. App-supplied values win. +func (c *Client) applyConfigDefaultsForMode(config *SessionConfig) { + if c.options.Mode != ModeEmpty { + return + } + if config.EnableSessionTelemetry == nil { + f := false + config.EnableSessionTelemetry = &f + } +} + +func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { + if c.options.Mode != ModeEmpty { + return + } + if config.EnableSessionTelemetry == nil { + f := false + config.EnableSessionTelemetry = &f + } +} + +// updateSessionOptionsForMode applies the per-mode safe-defaults patch via +// session.options.update after create/resume succeeds. In empty mode the +// four overridable feature flags default to safe values; caller values win. +// installedPlugins=[] is unconditional in empty mode. +func (c *Client) updateSessionOptionsForMode(ctx context.Context, session *Session, base optBackInFields) error { + patch := &rpc.SessionUpdateOptionsParams{} + hasAny := false + if c.options.Mode == ModeEmpty { + if base.SkipCustomInstructions != nil { + patch.SkipCustomInstructions = base.SkipCustomInstructions + } else { + t := true + patch.SkipCustomInstructions = &t + } + if base.CustomAgentsLocalOnly != nil { + patch.CustomAgentsLocalOnly = base.CustomAgentsLocalOnly + } else { + t := true + patch.CustomAgentsLocalOnly = &t + } + if base.CoauthorEnabled != nil { + patch.CoauthorEnabled = base.CoauthorEnabled + } else { + f := false + patch.CoauthorEnabled = &f + } + if base.ManageScheduleEnabled != nil { + patch.ManageScheduleEnabled = base.ManageScheduleEnabled + } else { + f := false + patch.ManageScheduleEnabled = &f + } + patch.InstalledPlugins = []rpc.SessionInstalledPlugin{} + hasAny = true + } else { + if base.SkipCustomInstructions != nil { + patch.SkipCustomInstructions = base.SkipCustomInstructions + hasAny = true + } + if base.CustomAgentsLocalOnly != nil { + patch.CustomAgentsLocalOnly = base.CustomAgentsLocalOnly + hasAny = true + } + if base.CoauthorEnabled != nil { + patch.CoauthorEnabled = base.CoauthorEnabled + hasAny = true + } + if base.ManageScheduleEnabled != nil { + patch.ManageScheduleEnabled = base.ManageScheduleEnabled + hasAny = true + } + } + if !hasAny { + return nil + } + _, err := session.RPC.Options.Update(ctx, patch) + return err +} + +// optBackInFields is the subset of SessionConfig / ResumeSessionConfig shared +// by [Client.updateSessionOptionsForMode]. +type optBackInFields struct { + SkipCustomInstructions *bool + CustomAgentsLocalOnly *bool + CoauthorEnabled *bool + ManageScheduleEnabled *bool +} diff --git a/go/toolset.go b/go/toolset.go new file mode 100644 index 000000000..6d6a62161 --- /dev/null +++ b/go/toolset.go @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package copilot + +import ( + "fmt" + "regexp" +) + +// ClientMode controls the default surface presented to sessions created by the +// [Client]. The zero value is [ModeCopilotCli], matching the legacy CLI defaults. +// +// Set [ClientOptions.Mode] to [ModeEmpty] to opt in to multi-tenant safe +// defaults: no built-in tools by default (callers must specify +// [SessionConfig.AvailableTools] explicitly), no environment_context section +// in the system message, telemetry off, custom instructions and remote-custom +// agents disabled, etc. +type ClientMode string + +const ( + // ModeCopilotCli is the default mode; sessions inherit the full Copilot + // CLI experience (all built-in tools, host environment_context, etc.). + ModeCopilotCli ClientMode = "copilot-cli" + // ModeEmpty is the multi-tenant safe-default mode. Sessions start with + // no built-in tools, no environment context, and various features + // (custom instructions, remote agents, telemetry, plugins) off by + // default. Callers can opt back in field-by-field. + ModeEmpty ClientMode = "empty" +) + +// ToolSet builds a list of source-qualified tool filter patterns +// (`builtin:*`, `mcp:`, `custom:*`, ...) for use with +// [SessionConfig.AvailableTools] or [SessionConfig.ExcludedTools]. +// +// 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. +// +// ToolSet's zero value is ready to use. ToolSet implements implicit conversion +// to []string via [ToolSet.ToSlice], and the [SessionConfig] fields accept +// []string directly; pass tools as `(&ToolSet{}).AddBuiltIn(...).ToSlice()`. +type ToolSet struct { + items []string +} + +// NewToolSet returns an empty [ToolSet]. +func NewToolSet() *ToolSet { return &ToolSet{} } + +var toolNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// AddBuiltIn adds one or more built-in tool patterns. Pass a specific tool +// name (e.g. "bash") or "*" to match all built-in tools. +func (s *ToolSet) AddBuiltIn(names ...string) *ToolSet { + for _, n := range names { + validateToolName("builtin", n) + s.items = append(s.items, "builtin:"+n) + } + return s +} + +// AddCustom adds a custom-tool pattern. Matches tools registered via +// [SessionConfig.Tools] or via custom agents. +func (s *ToolSet) AddCustom(name string) *ToolSet { + validateToolName("custom", name) + s.items = append(s.items, "custom:"+name) + return s +} + +// AddMcp adds an MCP tool pattern. Matches tools advertised by any configured +// MCP server. +func (s *ToolSet) AddMcp(toolName string) *ToolSet { + validateToolName("mcp", toolName) + s.items = append(s.items, "mcp:"+toolName) + return s +} + +// ToSlice returns a defensive copy of the accumulated filter strings. +func (s *ToolSet) ToSlice() []string { + out := make([]string, len(s.items)) + copy(out, s.items) + return out +} + +func validateToolName(kind, name string) { + if name == "" { + panic(fmt.Sprintf("invalid %s tool name: must not be empty", kind)) + } + if name == "*" { + return + } + if !toolNameRegex.MatchString(name) { + panic(fmt.Sprintf( + "invalid %s tool name %q: tool names must match /^[a-zA-Z0-9_-]+$/ or be the wildcard %q", + kind, name, "*")) + } +} + +// BuiltInToolsIsolated lists 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 [ModeEmpty] 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. +var BuiltInToolsIsolated = []string{ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", +} diff --git a/go/toolset_test.go b/go/toolset_test.go new file mode 100644 index 000000000..6992e1200 --- /dev/null +++ b/go/toolset_test.go @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package copilot + +import ( + "reflect" + "slices" + "strings" + "testing" +) + +func TestToolSet_emitsSourceQualifiedStrings(t *testing.T) { + items := NewToolSet(). + AddBuiltIn("bash"). + AddBuiltIn("*"). + AddCustom("my_tool"). + AddCustom("*"). + AddMcp("github-list_issues"). + AddMcp("*"). + ToSlice() + want := []string{ + "builtin:bash", + "builtin:*", + "custom:my_tool", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + } + if !reflect.DeepEqual(items, want) { + t.Errorf("got %v, want %v", items, want) + } +} + +func TestToolSet_addBuiltInVariadic(t *testing.T) { + items := NewToolSet().AddBuiltIn("bash", "view").ToSlice() + want := []string{"builtin:bash", "builtin:view"} + if !reflect.DeepEqual(items, want) { + t.Errorf("got %v, want %v", items, want) + } +} + +func TestToolSet_toSliceReturnsDefensiveCopy(t *testing.T) { + set := NewToolSet().AddBuiltIn("bash") + a := set.ToSlice() + a[0] = "builtin:tampered" + if got := set.ToSlice(); !reflect.DeepEqual(got, []string{"builtin:bash"}) { + t.Errorf("internal state mutated: %v", got) + } +} + +func TestToolSet_rejectsInvalidNames(t *testing.T) { + cases := []struct { + name string + fn func() + }{ + {"colon in builtin", func() { NewToolSet().AddBuiltIn("has:colon") }}, + {"space in mcp", func() { NewToolSet().AddMcp("has space") }}, + {"empty custom", func() { NewToolSet().AddCustom("") }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic, got none") + } + }() + c.fn() + }) + } +} + +func TestBuiltInToolsIsolated_membership(t *testing.T) { + for _, banned := range []string{"bash", "edit", "grep", "web_fetch"} { + if slices.Contains(BuiltInToolsIsolated, banned) { + t.Errorf("isolated set must not contain %q", banned) + } + } + for _, expected := range []string{"ask_user", "task_complete"} { + if !slices.Contains(BuiltInToolsIsolated, expected) { + t.Errorf("isolated set must contain %q", expected) + } + } +} + +func TestNewClient_modeEmptyRejectsWithoutStorage(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic, got none") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic, got %T", r) + } + if !strings.Contains(strings.ToLower(msg), "empty") { + t.Errorf("panic message should mention empty mode, got %q", msg) + } + }() + NewClient(&ClientOptions{Mode: ModeEmpty}) +} + +func TestNewClient_modeEmptyAcceptsBaseDirectory(t *testing.T) { + c := NewClient(&ClientOptions{ + Mode: ModeEmpty, + BaseDirectory: t.TempDir(), + }) + if c.options.Mode != ModeEmpty { + t.Errorf("expected ModeEmpty, got %q", c.options.Mode) + } +} + +func TestNewClient_modeEmptyAcceptsUriConnection(t *testing.T) { + c := NewClient(&ClientOptions{ + Mode: ModeEmpty, + Connection: UriConnection{URL: "8080"}, + }) + if c.options.Mode != ModeEmpty { + t.Errorf("expected ModeEmpty, got %q", c.options.Mode) + } +} + +func TestNewClient_modeCopilotCliIsDefault(t *testing.T) { + c := NewClient(nil) + if c.options.Mode != "" && c.options.Mode != ModeCopilotCli { + t.Errorf("expected default mode to be empty/copilot-cli, got %q", c.options.Mode) + } +} + +func TestValidateToolFilterList_rejectsBareWildcard(t *testing.T) { + err := validateToolFilterList("availableTools", []string{"builtin:bash", "*"}) + if err == nil { + t.Fatal("expected error for bare wildcard") + } + if !strings.Contains(err.Error(), "bare wildcard") { + t.Errorf("expected message about bare wildcard, got %q", err.Error()) + } +} + +func TestValidateToolFilterList_allowsSourceQualifiedWildcards(t *testing.T) { + if err := validateToolFilterList("availableTools", []string{"builtin:*", "mcp:*", "custom:*"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveToolFilterOptions_emptyModeRequiresAvailableTools(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + _, _, _, err := c.resolveToolFilterOptions(nil, nil) + if err == nil { + t.Fatal("expected error in empty mode without available tools") + } +} + +func TestResolveToolFilterOptions_setsExcludedPrecedence(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + _, _, precedence, err := c.resolveToolFilterOptions(nil, []string{"builtin:bash"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if precedence == nil || *precedence != "excluded" { + t.Errorf("expected precedence 'excluded', got %v", precedence) + } +} + +func TestSystemMessageForMode_emptyModeStripsEnvContextWhenNil(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + got := c.systemMessageForMode(nil) + if got == nil || got.Mode != "customize" { + t.Fatalf("expected customize mode, got %+v", got) + } + if action, ok := got.Sections["environment_context"]; !ok || action.Action != SectionActionRemove { + t.Errorf("expected environment_context: remove, got %+v", got.Sections) + } +} + +func TestSystemMessageForMode_emptyModePromotesAppendToCustomize(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + got := c.systemMessageForMode(&SystemMessageConfig{Mode: "append", Content: "extra"}) + if got.Mode != "customize" { + t.Errorf("expected customize, got %q", got.Mode) + } + if got.Content != "extra" { + t.Errorf("expected content preserved, got %q", got.Content) + } + if action, ok := got.Sections["environment_context"]; !ok || action.Action != SectionActionRemove { + t.Errorf("expected environment_context removed") + } +} + +func TestSystemMessageForMode_emptyModePreservesReplace(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + in := &SystemMessageConfig{Mode: "replace", Content: "whole prompt"} + got := c.systemMessageForMode(in) + if got != in { + t.Errorf("expected verbatim passthrough for replace, got %+v", got) + } +} + +func TestSystemMessageForMode_emptyModeRespectsCallerSection(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + in := &SystemMessageConfig{ + Mode: "customize", + Sections: map[string]SectionOverride{ + "environment_context": {Action: SectionActionReplace, Content: "custom"}, + }, + } + got := c.systemMessageForMode(in) + if got != in { + t.Errorf("expected caller's section override preserved verbatim") + } +} + +func TestSystemMessageForMode_copilotCliPassthrough(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + in := &SystemMessageConfig{Mode: "append", Content: "x"} + got := c.systemMessageForMode(in) + if got != in { + t.Errorf("non-empty mode must not alter system message") + } +} + +func TestApplyConfigDefaultsForMode_emptyDefaultsTelemetryFalse(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableSessionTelemetry == nil || *cfg.EnableSessionTelemetry != false { + t.Errorf("expected telemetry default false in empty mode, got %v", cfg.EnableSessionTelemetry) + } +} + +func TestApplyConfigDefaultsForMode_emptyHonorsCallerTelemetry(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + trueVal := true + cfg := &SessionConfig{EnableSessionTelemetry: &trueVal} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableSessionTelemetry == nil || *cfg.EnableSessionTelemetry != true { + t.Errorf("caller-supplied telemetry must win") + } +} + +func TestApplyConfigDefaultsForMode_copilotCliLeavesNil(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableSessionTelemetry != nil { + t.Errorf("non-empty mode must not default telemetry") + } +} diff --git a/go/types.go b/go/types.go index 52fd27eee..473c8cf2a 100644 --- a/go/types.go +++ b/go/types.go @@ -130,6 +130,15 @@ type ClientOptions struct { // directory are accessible from GitHub web and mobile. // Ignored when connecting to an existing runtime via [UriConnection]. EnableRemoteSessions bool + // Mode controls the default tool surface and feature flags presented to + // sessions created by this client. The zero value ([ModeCopilotCli]) + // matches legacy CLI defaults. Set to [ModeEmpty] to opt in to + // multi-tenant safe defaults — see [ClientMode] for details. + // + // When Mode is [ModeEmpty], NewClient requires either BaseDirectory, + // SessionFs, or a [UriConnection] so the runtime has persistent storage + // for session state. + Mode ClientMode } // CloudSessionRepository is GitHub repository metadata associated with a cloud session. @@ -920,6 +929,19 @@ type SessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // SkipCustomInstructions, when non-nil, controls whether the runtime loads + // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. + SkipCustomInstructions *bool + // CustomAgentsLocalOnly, when non-nil, restricts custom agents to those + // defined locally. See also [ClientOptions.Mode] = [ModeEmpty]. + CustomAgentsLocalOnly *bool + // CoauthorEnabled, when non-nil, controls whether the `coauthor` tool is + // exposed. See also [ClientOptions.Mode] = [ModeEmpty]. + CoauthorEnabled *bool + // ManageScheduleEnabled, when non-nil, controls whether the + // `manage_schedule` tool is exposed. See also [ClientOptions.Mode] = + // [ModeEmpty]. + ManageScheduleEnabled *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -1149,6 +1171,19 @@ type ResumeSessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // SkipCustomInstructions, when non-nil, controls whether the runtime loads + // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. + SkipCustomInstructions *bool + // CustomAgentsLocalOnly, when non-nil, restricts custom agents to those + // defined locally. See also [ClientOptions.Mode] = [ModeEmpty]. + CustomAgentsLocalOnly *bool + // CoauthorEnabled, when non-nil, controls whether the `coauthor` tool is + // exposed. See also [ClientOptions.Mode] = [ModeEmpty]. + CoauthorEnabled *bool + // ManageScheduleEnabled, when non-nil, controls whether the + // `manage_schedule` tool is exposed. See also [ClientOptions.Mode] = + // [ModeEmpty]. + ManageScheduleEnabled *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -1468,8 +1503,13 @@ type createSessionRequest struct { SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` + ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` + CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` + CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` + ManageScheduleEnabled *bool `json:"manageScheduleEnabled,omitempty"` ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` @@ -1526,8 +1566,13 @@ type resumeSessionRequest struct { SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` + ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` + CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` + CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` + ManageScheduleEnabled *bool `json:"manageScheduleEnabled,omitempty"` ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` From c99c6839706aab4a89b888a1000f4dfccbe55562 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 15:42:56 +0100 Subject: [PATCH 09/11] Python SDK: Add empty mode (ToolSet, BUILTIN_TOOLS_ISOLATED, safe defaults) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Node implementation from the same PR: - CopilotClientMode = Literal["copilot-cli", "empty"], default "copilot-cli" - ToolSet builder with add_builtin / add_mcp / add_custom; entries are source-qualified strings (builtin:*/mcp:*/custom:*) — no bare wildcard - BUILTIN_TOOLS_ISOLATED — built-ins safe for single-session, no-host-state contexts (ask_user, task_complete, exit_plan_mode, subagent helpers, …) - _CopilotClientOptions.mode + CopilotClient(mode=...) kwarg - Empty mode validates base_directory or session_fs or URI connection at construction time - create_session / resume_session: validate available_tools is set in empty mode; reject bare "*"; normalize ToolSet -> list[str]; transform system_message to strip environment_context; default enable_session_telemetry to False; always emit toolFilterPrecedence "excluded" - 4 opt-back-in SessionConfig fields: skip_custom_instructions, custom_agents_local_only, coauthor_enabled, manage_schedule_enabled — applied via session.options.update after create/resume. installedPlugins is forced to [] in empty mode. Failure to apply the patch tears the session down so empty-mode callers never end up with a permissive session. - Runtime spawn env: COPILOT_DISABLE_KEYTAR=1 when mode="empty" - Exports new symbols from `copilot` package - 40 unit tests in python/test_tool_set.py covering the builder and helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/__init__.py | 8 ++ python/copilot/_mode.py | 242 +++++++++++++++++++++++++++++++++++++ python/copilot/client.py | 136 ++++++++++++++++++++- python/test_tool_set.py | 235 +++++++++++++++++++++++++++++++++++ 4 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 python/copilot/_mode.py create mode 100644 python/test_tool_set.py diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 3808431d4..af5db5747 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -4,6 +4,11 @@ JSON-RPC based SDK for programmatic control of GitHub Copilot CLI """ +from ._mode import ( + BUILTIN_TOOLS_ISOLATED, + CopilotClientMode, + ToolSet, +) from .canvas import ( CanvasAction, CanvasDeclaration, @@ -144,6 +149,7 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", "CanvasError", @@ -157,6 +163,7 @@ "CommandContext", "CommandDefinition", "CopilotClient", + "CopilotClientMode", "CopilotSession", "CreateSessionFsHandler", "ElicitationContext", @@ -252,6 +259,7 @@ "ToolInvocation", "ToolResult", "ToolResultType", + "ToolSet", "UriRuntimeConnection", "UserInputHandler", "UserInputRequest", diff --git a/python/copilot/_mode.py b/python/copilot/_mode.py new file mode 100644 index 000000000..db4008f85 --- /dev/null +++ b/python/copilot/_mode.py @@ -0,0 +1,242 @@ +""" +Mode = "empty" support: ToolSet builder, BUILTIN_TOOLS_ISOLATED, and helpers +that translate Mode = "empty" into runtime-level session options. + +The runtime is mode-agnostic; the SDK is what turns ``mode="empty"`` into the +right combination of options on the wire (no environment_context, telemetry +off, custom instructions off, etc.). Callers can opt back in field-by-field. +""" + +from __future__ import annotations + +import re +from typing import Any, Iterable, Literal + +CopilotClientMode = Literal["copilot-cli", "empty"] + +_TOOL_NAME_REGEX = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def _validate_tool_name(kind: str, name: str) -> None: + if not name: + raise ValueError(f"invalid {kind} tool name: must not be empty") + if name == "*": + return + if not _TOOL_NAME_REGEX.match(name): + raise ValueError( + f"invalid {kind} tool name {name!r}: tool names must match " + r"/^[a-zA-Z0-9_-]+$/ or be the wildcard '*'" + ) + + +class ToolSet: + """Builder for source-qualified tool filter patterns. + + ``ToolSet`` accumulates entries like ``builtin:bash``, ``mcp:*``, or + ``custom:my_tool`` for use in + :class:`CopilotClient.create_session`'s ``available_tools`` / + ``excluded_tools`` parameters. + + Tool classification (``builtin``/``mcp``/``custom``) is determined by the + runtime at registration time — not by name parsing — so + ``add_builtin("foo")`` only matches tools the runtime registered as + built-in. + """ + + def __init__(self) -> None: + self._items: list[str] = [] + + def add_builtin(self, name: str | Iterable[str]) -> "ToolSet": + """Add a built-in tool pattern (``"bash"``/``"*"``/an iterable of names).""" + if isinstance(name, str): + _validate_tool_name("builtin", name) + self._items.append(f"builtin:{name}") + else: + for n in name: + _validate_tool_name("builtin", n) + self._items.append(f"builtin:{n}") + return self + + def add_custom(self, name: str) -> "ToolSet": + """Add a custom-tool pattern (e.g. ``"my_tool"`` or ``"*"``).""" + _validate_tool_name("custom", name) + self._items.append(f"custom:{name}") + return self + + def add_mcp(self, tool_name: str) -> "ToolSet": + """Add an MCP tool pattern (e.g. ``"github-list_issues"`` or ``"*"``).""" + _validate_tool_name("mcp", tool_name) + self._items.append(f"mcp:{tool_name}") + return self + + def to_list(self) -> list[str]: + """Return a defensive copy of the accumulated filter strings.""" + return list(self._items) + + def __iter__(self): + return iter(self.to_list()) + + def __len__(self) -> int: + return len(self._items) + + +#: Built-in tools that operate only within a single session — no host FS +#: access outside the session, no cross-session state, no host environment +#: access, no network. Safe to enable in ``mode="empty"`` scenarios 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. +BUILTIN_TOOLS_ISOLATED: list[str] = [ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", +] + + +def _normalize_tool_filter(value: Any) -> list[str] | None: + """Accept ``ToolSet``, ``list[str]``, or ``None``; return a list or ``None``.""" + if value is None: + return None + if isinstance(value, ToolSet): + return value.to_list() + return list(value) + + +def _validate_tool_filter_list(field: str, items: list[str] | None) -> None: + """Reject bare ``"*"`` entries (must use ``builtin:*``/``mcp:*``/``custom:*``).""" + if items is None: + return + for entry in items: + if entry == "*": + raise ValueError( + f"invalid {field} entry '*': there is no bare wildcard. " + "Use ToolSet().add_builtin('*'), .add_mcp('*'), or " + ".add_custom('*') to target a specific source." + ) + + +def _system_message_for_mode( + mode: CopilotClientMode | None, + supplied: Any, +) -> Any: + """Apply empty-mode environment_context stripping to a system message dict. + + The caller passes the already-normalized wire payload (a ``dict`` with + ``mode`` / ``content`` / ``sections``) or ``None``. The caller's value + wins if it already specifies an ``environment_context`` override. + """ + if mode != "empty": + return supplied + remove_action = {"action": "remove"} + if supplied is None: + return {"mode": "customize", "sections": {"environment_context": remove_action}} + supplied_mode = supplied.get("mode", "") + if supplied_mode == "replace": + return supplied + if supplied_mode == "customize": + sections = supplied.get("sections") or {} + if "environment_context" in sections: + return supplied + merged = {**supplied, "sections": {**sections, "environment_context": remove_action}} + return merged + # append (or unspecified): promote to customize so we can also strip + # environment_context. The runtime appends ``content`` in both modes, so + # the caller's text is preserved verbatim. + out: dict[str, Any] = { + "mode": "customize", + "sections": {"environment_context": remove_action}, + } + if "content" in supplied and supplied["content"] is not None: + out["content"] = supplied["content"] + return out + + +def _enable_session_telemetry_default( + mode: CopilotClientMode | None, + supplied: bool | None, +) -> bool | None: + """Empty mode defaults telemetry to False; caller value wins.""" + if mode == "empty" and supplied is None: + return False + return supplied + + +def _post_create_options_patch( + mode: CopilotClientMode | None, + skip_custom_instructions: bool | None, + custom_agents_local_only: bool | None, + coauthor_enabled: bool | None, + manage_schedule_enabled: bool | None, +) -> dict[str, Any] | None: + """Build the patch sent via ``session.options.update`` after create/resume. + + In empty mode the four overridable flags default to safe values + (caller-supplied values win); ``installedPlugins=[]`` is unconditional. + Returns ``None`` if no patch should be sent. + """ + if mode == "empty": + patch: dict[str, Any] = { + "skipCustomInstructions": ( + skip_custom_instructions if skip_custom_instructions is not None else True + ), + "customAgentsLocalOnly": ( + custom_agents_local_only if custom_agents_local_only is not None else True + ), + "coauthorEnabled": coauthor_enabled if coauthor_enabled is not None else False, + "manageScheduleEnabled": ( + manage_schedule_enabled if manage_schedule_enabled is not None else False + ), + "installedPlugins": [], + } + return patch + patch = {} + if skip_custom_instructions is not None: + patch["skipCustomInstructions"] = skip_custom_instructions + if custom_agents_local_only is not None: + patch["customAgentsLocalOnly"] = custom_agents_local_only + if coauthor_enabled is not None: + patch["coauthorEnabled"] = coauthor_enabled + if manage_schedule_enabled is not None: + patch["manageScheduleEnabled"] = manage_schedule_enabled + return patch or None + + +def _require_storage_for_empty_mode( + *, + mode: CopilotClientMode | None, + base_directory: str | None, + session_fs_set: bool, + is_uri_connection: bool, +) -> None: + if mode != "empty": + return + if base_directory or session_fs_set or is_uri_connection: + return + raise ValueError( + "CopilotClient(mode='empty') requires base_directory, session_fs, " + "or a UriRuntimeConnection. Empty mode needs explicit per-tenant " + "storage and won't fall back to ~/.copilot." + ) + + +def _require_available_tools_for_empty_mode( + mode: CopilotClientMode | None, + available_tools: list[str] | None, +) -> None: + if mode == "empty" and available_tools is None: + raise ValueError( + "CopilotClient is in mode='empty' but create_session was called " + "without available_tools. Empty mode requires every session to " + "explicitly opt into the tools it wants — e.g. " + "ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED)." + ) diff --git a/python/copilot/client.py b/python/copilot/client.py index 4386adb08..c6242e802 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -34,6 +34,18 @@ from ._diagnostics import log_timing from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError +from ._mode import ( + BUILTIN_TOOLS_ISOLATED, + CopilotClientMode, + ToolSet, + _enable_session_telemetry_default, + _normalize_tool_filter, + _post_create_options_patch, + _require_available_tools_for_empty_mode, + _require_storage_for_empty_mode, + _system_message_for_mode, + _validate_tool_filter_list, +) from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context from .canvas import ( @@ -303,6 +315,7 @@ class _CopilotClientOptions: session_idle_timeout_seconds: int | None = None enable_remote_sessions: bool = False on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None + mode: CopilotClientMode = "copilot-cli" # ============================================================================ @@ -1051,6 +1064,7 @@ def __init__( session_idle_timeout_seconds: int | None = None, enable_remote_sessions: bool = False, on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None, + mode: CopilotClientMode = "copilot-cli", ): """ Initialize a new CopilotClient. @@ -1120,10 +1134,17 @@ def __init__( session_idle_timeout_seconds=session_idle_timeout_seconds, enable_remote_sessions=enable_remote_sessions, on_list_models=on_list_models, + mode=mode, ) connection = ( options.connection if options.connection is not None else RuntimeConnection.for_stdio() ) + _require_storage_for_empty_mode( + mode=options.mode, + base_directory=options.base_directory, + session_fs_set=options.session_fs is not None, + is_uri_connection=isinstance(connection, UriRuntimeConnection), + ) self._options: _CopilotClientOptions = options self._connection: RuntimeConnection = connection @@ -1521,13 +1542,17 @@ async def create_session( reasoning_effort: ReasoningEffort | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, - available_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, + available_tools: "list[str] | ToolSet | None" = None, + excluded_tools: "list[str] | ToolSet | None" = None, on_user_input_request: UserInputHandler | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, enable_session_telemetry: bool | None = None, + skip_custom_instructions: bool | None = None, + custom_agents_local_only: bool | None = None, + coauthor_enabled: bool | None = None, + manage_schedule_enabled: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -1660,6 +1685,18 @@ async def create_session( definition["skipPermission"] = True tool_defs.append(definition) + # Empty-mode validation and normalization + mode = self._options.mode + _require_available_tools_for_empty_mode(mode, _normalize_tool_filter(available_tools)) + available_tools = _normalize_tool_filter(available_tools) + excluded_tools = _normalize_tool_filter(excluded_tools) + _validate_tool_filter_list("available_tools", available_tools) + _validate_tool_filter_list("excluded_tools", excluded_tools) + # Mode "empty" strips environment_context from the system message. + system_message = _system_message_for_mode(mode, system_message) + # Mode "empty" defaults telemetry to off; caller wins. + enable_session_telemetry = _enable_session_telemetry_default(mode, enable_session_telemetry) + payload: dict[str, Any] = {} if model: payload["model"] = model @@ -1678,6 +1715,9 @@ async def create_session( payload["availableTools"] = available_tools if excluded_tools is not None: payload["excludedTools"] = excluded_tools + # Always emit "excluded" precedence so caller-supplied excludedTools win + # over any built-in availableTools defaults the runtime applies. + payload["toolFilterPrecedence"] = "excluded" # Enable permission request callback if handler provided payload["requestPermission"] = bool(on_permission_request) @@ -1893,6 +1933,15 @@ async def create_session( ) raise + await self._apply_post_create_options_patch( + session, + mode, + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + ) + log_timing( logger, logging.DEBUG, @@ -1912,13 +1961,17 @@ async def resume_session( reasoning_effort: ReasoningEffort | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, - available_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, + available_tools: "list[str] | ToolSet | None" = None, + excluded_tools: "list[str] | ToolSet | None" = None, on_user_input_request: UserInputHandler | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, enable_session_telemetry: bool | None = None, + skip_custom_instructions: bool | None = None, + custom_agents_local_only: bool | None = None, + coauthor_enabled: bool | None = None, + manage_schedule_enabled: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -2055,6 +2108,16 @@ async def resume_session( definition["skipPermission"] = True tool_defs.append(definition) + # Empty-mode validation and normalization + mode = self._options.mode + _require_available_tools_for_empty_mode(mode, _normalize_tool_filter(available_tools)) + available_tools = _normalize_tool_filter(available_tools) + excluded_tools = _normalize_tool_filter(excluded_tools) + _validate_tool_filter_list("available_tools", available_tools) + _validate_tool_filter_list("excluded_tools", excluded_tools) + system_message = _system_message_for_mode(mode, system_message) + enable_session_telemetry = _enable_session_telemetry_default(mode, enable_session_telemetry) + payload: dict[str, Any] = {"sessionId": session_id} if client_name: @@ -2072,6 +2135,7 @@ async def resume_session( payload["availableTools"] = available_tools if excluded_tools is not None: payload["excludedTools"] = excluded_tools + payload["toolFilterPrecedence"] = "excluded" if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) if enable_session_telemetry is not None: @@ -2267,6 +2331,15 @@ async def resume_session( ) raise + await self._apply_post_create_options_patch( + session, + mode, + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + ) + log_timing( logger, logging.DEBUG, @@ -2871,6 +2944,11 @@ async def _start_cli_server(self) -> None: if opts.github_token: env["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token + # Mode "empty": disable the runtime's system keychain probe so per-tenant + # credentials don't leak through a shared keytar store. + if opts.mode == "empty": + env["COPILOT_DISABLE_KEYTAR"] = "1" + if self._effective_connection_token: env["COPILOT_CONNECTION_TOKEN"] = self._effective_connection_token if opts.base_directory: @@ -3159,6 +3237,56 @@ def handle_notification(method: str, params: dict): loop = asyncio.get_running_loop() self._client.start(loop) + async def _apply_post_create_options_patch( + self, + session: "CopilotSession", + mode: CopilotClientMode, + skip_custom_instructions: bool | None, + custom_agents_local_only: bool | None, + coauthor_enabled: bool | None, + manage_schedule_enabled: bool | None, + ) -> None: + """Apply empty-mode safe defaults (or caller-supplied overrides in + copilot-cli mode) via ``session.options.update`` after create/resume. + + If the patch is rejected, tear the session down so empty-mode callers + never end up with a permissive session. + """ + from .generated.rpc import SessionUpdateOptionsParams, SessionInstalledPlugin + + patch = _post_create_options_patch( + mode, + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + ) + if patch is None: + return + + params = SessionUpdateOptionsParams() + if "skipCustomInstructions" in patch: + params.skip_custom_instructions = patch["skipCustomInstructions"] + if "customAgentsLocalOnly" in patch: + params.custom_agents_local_only = patch["customAgentsLocalOnly"] + if "coauthorEnabled" in patch: + params.coauthor_enabled = patch["coauthorEnabled"] + if "manageScheduleEnabled" in patch: + params.manage_schedule_enabled = patch["manageScheduleEnabled"] + if "installedPlugins" in patch: + params.installed_plugins = [ + SessionInstalledPlugin.from_dict(p) if isinstance(p, dict) else p + for p in patch["installedPlugins"] + ] + + try: + await session.rpc.options.update(params) + except BaseException: + with self._sessions_lock: + self._sessions.pop(session.session_id, None) + await session.disconnect() + raise + async def _set_session_fs_provider(self) -> None: if not self._session_fs_config or not self._client: return diff --git a/python/test_tool_set.py b/python/test_tool_set.py new file mode 100644 index 000000000..3d80d29e8 --- /dev/null +++ b/python/test_tool_set.py @@ -0,0 +1,235 @@ +"""Unit tests for the ``ToolSet`` builder and empty-mode helpers.""" + +from __future__ import annotations + +import pytest + +from copilot import BUILTIN_TOOLS_ISOLATED, CopilotClient, ToolSet, UriRuntimeConnection +from copilot._mode import ( + _enable_session_telemetry_default, + _post_create_options_patch, + _require_available_tools_for_empty_mode, + _require_storage_for_empty_mode, + _system_message_for_mode, + _validate_tool_filter_list, +) + + +class TestToolSet: + def test_add_builtin_string(self): + ts = ToolSet().add_builtin("bash") + assert ts.to_list() == ["builtin:bash"] + + def test_add_builtin_wildcard(self): + ts = ToolSet().add_builtin("*") + assert ts.to_list() == ["builtin:*"] + + def test_add_builtin_iterable(self): + ts = ToolSet().add_builtin(["bash", "edit"]) + assert ts.to_list() == ["builtin:bash", "builtin:edit"] + + def test_add_builtin_isolated(self): + ts = ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED) + assert ts.to_list() == [f"builtin:{name}" for name in BUILTIN_TOOLS_ISOLATED] + + def test_add_mcp(self): + ts = ToolSet().add_mcp("github-list_issues") + assert ts.to_list() == ["mcp:github-list_issues"] + + def test_add_mcp_wildcard(self): + assert ToolSet().add_mcp("*").to_list() == ["mcp:*"] + + def test_add_custom(self): + assert ToolSet().add_custom("my_tool").to_list() == ["custom:my_tool"] + + def test_chained(self): + ts = ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED).add_mcp("*").add_custom("*") + assert ts.to_list()[-2:] == ["mcp:*", "custom:*"] + + def test_rejects_bad_name(self): + with pytest.raises(ValueError, match="tool names must match"): + ToolSet().add_builtin("has space") + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="must not be empty"): + ToolSet().add_custom("") + + def test_rejects_colon(self): + with pytest.raises(ValueError, match="tool names must match"): + ToolSet().add_mcp("server:tool") + + def test_iterable_protocol(self): + ts = ToolSet().add_builtin("bash").add_mcp("*") + assert list(ts) == ["builtin:bash", "mcp:*"] + assert len(ts) == 2 + + +class TestEmptyModeValidation: + def test_empty_mode_requires_storage(self): + with pytest.raises(ValueError, match="requires base_directory"): + _require_storage_for_empty_mode( + mode="empty", + base_directory=None, + session_fs_set=False, + is_uri_connection=False, + ) + + def test_empty_mode_accepts_base_directory(self): + _require_storage_for_empty_mode( + mode="empty", + base_directory="/tmp/x", + session_fs_set=False, + is_uri_connection=False, + ) + + def test_empty_mode_accepts_session_fs(self): + _require_storage_for_empty_mode( + mode="empty", + base_directory=None, + session_fs_set=True, + is_uri_connection=False, + ) + + def test_empty_mode_accepts_uri_connection(self): + _require_storage_for_empty_mode( + mode="empty", + base_directory=None, + session_fs_set=False, + is_uri_connection=True, + ) + + def test_copilot_cli_mode_no_storage_required(self): + _require_storage_for_empty_mode( + mode="copilot-cli", + base_directory=None, + session_fs_set=False, + is_uri_connection=False, + ) + + def test_empty_mode_requires_available_tools(self): + with pytest.raises(ValueError, match="available_tools"): + _require_available_tools_for_empty_mode("empty", None) + + def test_empty_mode_accepts_available_tools(self): + _require_available_tools_for_empty_mode("empty", ["builtin:bash"]) + + def test_copilot_cli_mode_no_tool_filter_required(self): + _require_available_tools_for_empty_mode("copilot-cli", None) + + +class TestToolFilterListValidation: + def test_rejects_bare_wildcard(self): + with pytest.raises(ValueError, match="bare wildcard"): + _validate_tool_filter_list("available_tools", ["*"]) + + def test_accepts_source_qualified_wildcard(self): + _validate_tool_filter_list("available_tools", ["builtin:*", "mcp:*"]) + + def test_accepts_none(self): + _validate_tool_filter_list("available_tools", None) + + +class TestSystemMessageForMode: + def test_copilot_cli_pass_through(self): + assert _system_message_for_mode("copilot-cli", None) is None + msg = {"mode": "append", "content": "hi"} + assert _system_message_for_mode("copilot-cli", msg) is msg + + def test_empty_mode_none_supplied(self): + out = _system_message_for_mode("empty", None) + assert out == { + "mode": "customize", + "sections": {"environment_context": {"action": "remove"}}, + } + + def test_empty_mode_replace_pass_through(self): + msg = {"mode": "replace", "content": "verbatim"} + assert _system_message_for_mode("empty", msg) is msg + + def test_empty_mode_customize_adds_section(self): + msg = {"mode": "customize", "sections": {"identity": {"action": "remove"}}} + out = _system_message_for_mode("empty", msg) + assert out["sections"]["environment_context"] == {"action": "remove"} + assert out["sections"]["identity"] == {"action": "remove"} + + def test_empty_mode_customize_does_not_overwrite_existing(self): + msg = { + "mode": "customize", + "sections": {"environment_context": {"action": "replace", "content": "X"}}, + } + assert _system_message_for_mode("empty", msg) is msg + + def test_empty_mode_append_promoted_to_customize(self): + msg = {"mode": "append", "content": "tip"} + out = _system_message_for_mode("empty", msg) + assert out["mode"] == "customize" + assert out["content"] == "tip" + assert out["sections"]["environment_context"] == {"action": "remove"} + + +class TestTelemetryDefault: + def test_empty_mode_defaults_to_false(self): + assert _enable_session_telemetry_default("empty", None) is False + + def test_empty_mode_caller_wins(self): + assert _enable_session_telemetry_default("empty", True) is True + + def test_copilot_cli_does_not_change(self): + assert _enable_session_telemetry_default("copilot-cli", None) is None + + +class TestPostCreatePatch: + def test_empty_mode_defaults(self): + patch = _post_create_options_patch("empty", None, None, None, None) + assert patch == { + "skipCustomInstructions": True, + "customAgentsLocalOnly": True, + "coauthorEnabled": False, + "manageScheduleEnabled": False, + "installedPlugins": [], + } + + def test_empty_mode_caller_wins(self): + patch = _post_create_options_patch("empty", False, False, True, True) + assert patch == { + "skipCustomInstructions": False, + "customAgentsLocalOnly": False, + "coauthorEnabled": True, + "manageScheduleEnabled": True, + "installedPlugins": [], + } + + def test_copilot_cli_returns_none_when_unset(self): + assert _post_create_options_patch("copilot-cli", None, None, None, None) is None + + def test_copilot_cli_passes_through_explicit_values(self): + patch = _post_create_options_patch("copilot-cli", True, None, False, None) + assert patch == {"skipCustomInstructions": True, "coauthorEnabled": False} + + +class TestClientConstruction: + def test_empty_mode_without_storage_raises(self): + with pytest.raises(ValueError, match="requires base_directory"): + CopilotClient(mode="empty") + + def test_empty_mode_with_base_directory_ok(self, tmp_path): + # Use URI connection to skip bundled-CLI discovery. + client = CopilotClient( + mode="empty", + base_directory=str(tmp_path), + connection=UriRuntimeConnection(url="http://localhost:1234"), + ) + assert client._options.mode == "empty" + + def test_empty_mode_with_uri_connection_ok(self): + client = CopilotClient( + mode="empty", + connection=UriRuntimeConnection(url="http://localhost:1234"), + ) + assert client._options.mode == "empty" + + def test_default_mode_copilot_cli(self): + client = CopilotClient( + connection=UriRuntimeConnection(url="http://localhost:1234"), + ) + assert client._options.mode == "copilot-cli" From 17a9e1c7d91604756055fa4e4ca1a5b84d0267e7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 16:01:27 +0100 Subject: [PATCH 10/11] Wire empty-mode SDK behavior into create/resume_session Ports the empty-mode SDK feature from Node (commits 78e7280c..61f86942) to the Rust SDK: - Add `tool_filter_precedence: "excluded"` to SessionCreate/SessionResume wire payloads (always sent, matching Node). - `create_session` / `resume_session`: - Reject empty mode when `available_tools` is unset. - Validate `available_tools` / `excluded_tools` reject bare "*". - Apply `system_message_for_mode` to strip `environment_context` in empty mode unless the caller has already overridden it. - Default `enable_session_telemetry = false` in empty mode when unset. - After session creation succeeds, send a `session.options.update` patch with safe defaults (skipCustomInstructions=true, customAgentsLocalOnly=true, coauthorEnabled=false, manageScheduleEnabled=false, installedPlugins=[]). If the patch fails, disconnect the session and propagate the error. - In copilot-cli mode, the same patch is sent only for fields the caller explicitly provided on the SessionConfig. - Add `with_*` builders on SessionConfig and ResumeSessionConfig for the four opt-back-in fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/lib.rs | 87 ++++++++- rust/src/mode.rs | 461 ++++++++++++++++++++++++++++++++++++++++++++ rust/src/session.rs | 121 +++++++++++- rust/src/types.rs | 82 ++++++++ rust/src/wire.rs | 5 + 5 files changed, 750 insertions(+), 6 deletions(-) create mode 100644 rust/src/mode.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cad6ee629..98d0a30e0 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -38,6 +38,12 @@ mod wire; /// Auto-generated protocol types from Copilot JSON Schemas. pub mod generated; +/// Client-level mode ([`ClientMode`]) and the [`ToolSet`] builder for +/// source-qualified tool filter patterns. +pub mod mode; + +pub use mode::{BUILTIN_TOOLS_ISOLATED, ClientMode, ToolSet}; + use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -423,9 +429,24 @@ pub struct ClientOptions { /// redirect the extraction (e.g. to a session-scoped temp directory in /// CI runners) without changing the global cache layout. /// + /// Override the directory where the bundled CLI binary is extracted on + /// first use. + /// + /// When `None` (the default), the SDK extracts the embedded CLI to + /// `/github-copilot-sdk-{version}/copilot[.exe]`, + /// where the cache dir is [`dirs::cache_dir()`] — + /// `%LOCALAPPDATA%` on Windows, `~/Library/Caches/` on macOS, + /// `$XDG_CACHE_HOME` (or `~/.cache/`) on Linux. Use this knob to + /// redirect the extraction (e.g. to a session-scoped temp directory in + /// CI runners) without changing the global cache layout. + /// /// Ignored when the SDK was built without a bundled CLI (i.e. with /// `default-features = false` to disable the `bundled-cli` feature). pub bundled_cli_extract_dir: Option, + /// SDK-level mode controlling whether sessions get CLI-style defaults + /// (the default) or are stripped to a minimal/safe baseline. See + /// [`ClientMode`] for the contract and trade-offs. + pub mode: ClientMode, } impl std::fmt::Debug for ClientOptions { @@ -668,6 +689,7 @@ impl Default for ClientOptions { base_directory: None, enable_remote_sessions: false, bundled_cli_extract_dir: None, + mode: ClientMode::default(), } } } @@ -831,6 +853,15 @@ impl ClientOptions { self.bundled_cli_extract_dir = Some(dir.into()); self } + + /// Set the SDK [`ClientMode`]. Use [`ClientMode::Empty`] for any + /// scenario where CLI-like ambient behavior is unsafe (e.g. multi-user + /// servers). Empty mode additionally requires [`Self::base_directory`] + /// or [`Self::session_fs`] to be set, validated at [`Client::start`]. + pub fn with_mode(mut self, mode: ClientMode) -> Self { + self.mode = mode; + self + } } /// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`. @@ -904,6 +935,9 @@ struct ClientInner { /// `None` for stdio and for external-server transport without an /// explicit token. effective_connection_token: Option, + /// SDK [`ClientMode`] captured at start time. Drives empty-mode safe + /// defaults inside `create_session` / `resume_session`. + pub(crate) mode: ClientMode, } impl Client { @@ -921,6 +955,16 @@ impl Client { /// backend. pub async fn start(options: ClientOptions) -> Result { let start_time = Instant::now(); + if options.mode == ClientMode::Empty + && options.base_directory.is_none() + && options.session_fs.is_none() + { + return Err(Error::InvalidConfig( + "ClientMode::Empty requires either `base_directory` or \ + `session_fs` to be set (no implicit ~/.copilot fallback)." + .to_string(), + )); + } if let Some(cfg) = &options.session_fs { validate_session_fs_config(cfg)?; } @@ -1036,6 +1080,7 @@ impl Client { session_fs_sqlite_declared, options.on_get_trace_context, effective_connection_token.clone(), + options.mode, )? } Transport::Tcp { @@ -1062,6 +1107,7 @@ impl Client { session_fs_sqlite_declared, options.on_get_trace_context, effective_connection_token.clone(), + options.mode, )? } Transport::Stdio => { @@ -1079,6 +1125,7 @@ impl Client { session_fs_sqlite_declared, options.on_get_trace_context, effective_connection_token.clone(), + options.mode, )? } }; @@ -1126,7 +1173,18 @@ impl Client { writer: impl AsyncWrite + Unpin + Send + 'static, cwd: PathBuf, ) -> Result { - Self::from_transport(reader, writer, None, cwd, None, false, false, None, None) + Self::from_transport( + reader, + writer, + None, + cwd, + None, + false, + false, + None, + None, + ClientMode::default(), + ) } /// Construct a [`Client`] from raw streams with a @@ -1153,6 +1211,7 @@ impl Client { false, Some(provider), None, + ClientMode::default(), ) } @@ -1166,7 +1225,18 @@ impl Client { cwd: PathBuf, token: Option, ) -> Result { - Self::from_transport(reader, writer, None, cwd, None, false, false, None, token) + Self::from_transport( + reader, + writer, + None, + cwd, + None, + false, + false, + None, + token, + ClientMode::default(), + ) } /// Public test-only wrapper around the random connection-token @@ -1190,6 +1260,7 @@ impl Client { session_fs_sqlite_declared: bool, on_get_trace_context: Option>, effective_connection_token: Option, + mode: ClientMode, ) -> Result { let setup_start = Instant::now(); let (request_tx, request_rx) = mpsc::unbounded_channel::(); @@ -1221,6 +1292,7 @@ impl Client { session_fs_sqlite_declared, on_get_trace_context, effective_connection_token, + mode, }), }; client.spawn_lifecycle_dispatcher(); @@ -1308,6 +1380,11 @@ impl Client { if let Some(dir) = &options.base_directory { command.env("COPILOT_HOME", dir); } + // Empty mode disables the process-wide system keychain so the CLI + // falls back to file-based credentials scoped to COPILOT_HOME. + if options.mode == ClientMode::Empty { + command.env("COPILOT_DISABLE_KEYTAR", "1"); + } if let Transport::Tcp { connection_token: Some(token), .. @@ -1489,6 +1566,11 @@ impl Client { &self.inner.cwd } + /// Returns the SDK [`ClientMode`] this client was started with. + pub fn mode(&self) -> ClientMode { + self.inner.mode + } + /// Typed RPC namespace for server-level methods. /// /// Every protocol method lives here under its schema-aligned path — @@ -2607,6 +2689,7 @@ mod tests { session_fs_sqlite_declared: false, on_get_trace_context: None, effective_connection_token: None, + mode: ClientMode::default(), }), } } diff --git a/rust/src/mode.rs b/rust/src/mode.rs new file mode 100644 index 000000000..01d1038b1 --- /dev/null +++ b/rust/src/mode.rs @@ -0,0 +1,461 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +//! Client-level "empty" mode for minimal/safe defaults. +//! +//! See the plan in : +//! [`ClientMode::Empty`] disables ambient CLI-style behavior by default so an +//! app must explicitly opt back into features. This module exposes the public +//! enum, the [`ToolSet`] builder for source-qualified tool filter patterns, +//! and the [`BUILTIN_TOOLS_ISOLATED`] curated allowlist. + +use std::collections::HashMap; + +use crate::types::{SectionOverride, SystemMessageConfig}; + +/// Controls SDK defaults for ambient CLI-style behavior. +/// +/// - [`ClientMode::CopilotCli`] (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. +/// - [`ClientMode::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). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClientMode { + /// Defaults equivalent to Copilot CLI (the default). + #[default] + CopilotCli, + /// Disables optional features by default; app must opt in explicitly. + Empty, +} + +/// Tool name character set enforced by the runtime at every registration +/// boundary. Mirrors the runtime's `VALID_TOOL_NAME_REGEX`. +fn is_valid_tool_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn validate_name(kind: &str, name: &str) -> Result<(), crate::Error> { + if name == "*" { + return Ok(()); + } + if !is_valid_tool_name(name) { + return Err(crate::Error::InvalidConfig(format!( + "Invalid {kind} tool name '{name}': tool names must match \ + /^[a-zA-Z0-9_-]+$/ or be the wildcard '*'." + ))); + } + Ok(()) +} + +/// Builder that produces source-qualified tool filter strings (e.g. +/// `"builtin:bash"`, `"mcp:*"`, `"custom:foo"`) for the session's +/// `available_tools` list. +/// +/// Tools are classified by the runtime at registration time, not from name +/// parsing — so `add_builtin("foo")` matches only tools registered as +/// built-in, even if an MCP server happens to register a tool with the same +/// wire name. +/// +/// # Example +/// +/// ``` +/// # use github_copilot_sdk::mode::{ToolSet, BUILTIN_TOOLS_ISOLATED}; +/// let tools = ToolSet::new() +/// .add_builtin_many(BUILTIN_TOOLS_ISOLATED)? +/// .add_mcp("*")? +/// .add_custom("*")? +/// .to_vec(); +/// # Ok::<(), github_copilot_sdk::Error>(()) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ToolSet { + items: Vec, +} + +impl ToolSet { + /// Construct an empty tool set. + pub fn new() -> Self { + Self::default() + } + + /// Add a single built-in tool pattern. Pass a specific name (e.g. + /// `"bash"`) or `"*"` to match all built-in tools. + pub fn add_builtin(mut self, name: &str) -> Result { + validate_name("builtin", name)?; + self.items.push(format!("builtin:{name}")); + Ok(self) + } + + /// Add a list of built-in tool patterns (e.g. [`BUILTIN_TOOLS_ISOLATED`]). + pub fn add_builtin_many(mut self, names: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + for name in names { + let name = name.as_ref(); + validate_name("builtin", name)?; + self.items.push(format!("builtin:{name}")); + } + Ok(self) + } + + /// Add a custom tool pattern. Matches tools registered via the SDK's + /// `tools` option or via custom agents. + pub fn add_custom(mut self, name: &str) -> Result { + validate_name("custom", name)?; + self.items.push(format!("custom:{name}")); + Ok(self) + } + + /// Add an MCP tool pattern. Pass the runtime's canonical wire name + /// (e.g. `"github-list_issues"`) or `"*"` to match all MCP tools. + pub fn add_mcp(mut self, tool_name: &str) -> Result { + validate_name("mcp", tool_name)?; + self.items.push(format!("mcp:{tool_name}")); + Ok(self) + } + + /// Returns a defensive copy of the accumulated filter strings. + pub fn to_vec(&self) -> Vec { + self.items.clone() + } + + /// Returns the accumulated filter strings, consuming the builder. + pub fn into_vec(self) -> Vec { + self.items + } + + /// Number of accumulated filter strings. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns `true` if no filter strings have been added. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +impl From for Vec { + fn from(value: ToolSet) -> Self { + value.into_vec() + } +} + +/// 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 [`ClientMode::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. +pub const BUILTIN_TOOLS_ISOLATED: &[&str] = &[ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", +]; + +/// Validate a tool filter list (`available_tools` or `excluded_tools`). +/// Rejects the bare `"*"` shorthand with a clear error pointing the developer +/// at the source-qualified forms. +pub(crate) fn validate_tool_filter_list( + field: &str, + list: Option<&[String]>, +) -> Result<(), crate::Error> { + let Some(list) = list else { return Ok(()) }; + for item in list { + if item == "*" { + return Err(crate::Error::InvalidConfig(format!( + "{field} contains a bare '*' which matches no tool. Use \ + source-qualified wildcards instead: \ + ToolSet::new().add_builtin(\"*\").add_mcp(\"*\").add_custom(\"*\")." + ))); + } + } + Ok(()) +} + +/// Returns the system message config to use, adjusted for the current mode. +/// In empty mode we ensure the `environment_context` section is removed +/// unless the app has already taken control of it. +pub(crate) fn system_message_for_mode( + mode: ClientMode, + supplied: Option, +) -> Option { + if mode != ClientMode::Empty { + return supplied; + } + let strip_env = || { + let mut sections = HashMap::new(); + sections.insert( + "environment_context".to_string(), + SectionOverride { + action: Some("remove".to_string()), + content: None, + }, + ); + sections + }; + let Some(supplied) = supplied else { + return Some(SystemMessageConfig { + mode: Some("customize".to_string()), + content: None, + sections: Some(strip_env()), + }); + }; + let mode_str = supplied.mode.as_deref().unwrap_or("append"); + match mode_str { + "replace" => Some(supplied), + "customize" => { + if supplied + .sections + .as_ref() + .is_some_and(|s| s.contains_key("environment_context")) + { + Some(supplied) + } else { + let mut sections = supplied.sections.unwrap_or_default(); + sections.insert( + "environment_context".to_string(), + SectionOverride { + action: Some("remove".to_string()), + content: None, + }, + ); + Some(SystemMessageConfig { + mode: Some("customize".to_string()), + content: supplied.content, + sections: Some(sections), + }) + } + } + // "append" or any unrecognized value: promote to customize so we + // can also strip environment_context; the runtime appends `content` + // to additional instructions either way. + _ => Some(SystemMessageConfig { + mode: Some("customize".to_string()), + content: supplied.content, + sections: Some(strip_env()), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_set_emits_source_qualified_patterns() { + let v = ToolSet::new() + .add_builtin("bash") + .unwrap() + .add_builtin("*") + .unwrap() + .add_custom("foo") + .unwrap() + .add_custom("*") + .unwrap() + .add_mcp("github-list_issues") + .unwrap() + .add_mcp("*") + .unwrap() + .to_vec(); + assert_eq!( + v, + vec![ + "builtin:bash", + "builtin:*", + "custom:foo", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + ] + ); + } + + #[test] + fn tool_set_add_builtin_many() { + let v = ToolSet::new() + .add_builtin_many(BUILTIN_TOOLS_ISOLATED) + .unwrap() + .into_vec(); + assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len()); + assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0])); + } + + #[test] + fn tool_set_rejects_invalid_names() { + for bad in ["bash!", "with space", "colon:name", "", "wild*card"] { + assert!( + ToolSet::new().add_builtin(bad).is_err(), + "expected '{bad}' to be rejected" + ); + assert!(ToolSet::new().add_custom(bad).is_err()); + assert!(ToolSet::new().add_mcp(bad).is_err()); + } + } + + #[test] + fn tool_set_accepts_wildcard_and_underscores_and_dashes() { + assert!(ToolSet::new().add_builtin("*").is_ok()); + assert!(ToolSet::new().add_mcp("github-list_issues").is_ok()); + assert!(ToolSet::new().add_custom("A_b-9").is_ok()); + } + + #[test] + fn into_vec_is_idempotent_with_to_vec() { + let ts = ToolSet::new().add_builtin("bash").unwrap(); + assert_eq!(ts.to_vec(), vec!["builtin:bash"]); + assert_eq!(ts.into_vec(), vec!["builtin:bash"]); + } + + #[test] + fn into_vec_string_conversion() { + let v: Vec = ToolSet::new().add_mcp("*").unwrap().into(); + assert_eq!(v, vec!["mcp:*"]); + } + + #[test] + fn validate_tool_filter_list_rejects_bare_star() { + let bad = vec!["*".to_string()]; + assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err()); + } + + #[test] + fn validate_tool_filter_list_allows_qualified_star() { + let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()]; + assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok()); + } + + #[test] + fn validate_tool_filter_list_none_is_ok() { + assert!(validate_tool_filter_list("availableTools", None).is_ok()); + } + + #[test] + fn builtin_tools_isolated_contents() { + assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user")); + assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete")); + assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill")); + assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash")); + assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit")); + assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch")); + } + + #[test] + fn client_mode_default_is_copilot_cli() { + assert_eq!(ClientMode::default(), ClientMode::CopilotCli); + } + + #[test] + fn system_message_copilot_cli_passes_through_unchanged() { + let cfg = SystemMessageConfig { + mode: Some("append".to_string()), + content: Some("hello".to_string()), + sections: None, + }; + let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone())); + let out = out.unwrap(); + assert_eq!(out.mode.as_deref(), Some("append")); + assert_eq!(out.content.as_deref(), Some("hello")); + } + + #[test] + fn system_message_empty_none_injects_strip() { + let out = system_message_for_mode(ClientMode::Empty, None).unwrap(); + assert_eq!(out.mode.as_deref(), Some("customize")); + let sections = out.sections.unwrap(); + let env = sections.get("environment_context").unwrap(); + assert_eq!(env.action.as_deref(), Some("remove")); + } + + #[test] + fn system_message_empty_append_promoted_to_customize() { + let cfg = SystemMessageConfig { + mode: Some("append".to_string()), + content: Some("hi".to_string()), + sections: None, + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap(); + assert_eq!(out.mode.as_deref(), Some("customize")); + assert_eq!(out.content.as_deref(), Some("hi")); + let sections = out.sections.unwrap(); + assert!(sections.contains_key("environment_context")); + } + + #[test] + fn system_message_empty_replace_passes_through() { + let cfg = SystemMessageConfig { + mode: Some("replace".to_string()), + content: Some("verbatim".to_string()), + sections: None, + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap(); + assert_eq!(out.mode.as_deref(), Some("replace")); + assert_eq!(out.content.as_deref(), Some("verbatim")); + assert!(out.sections.is_none()); + } + + #[test] + fn system_message_empty_customize_with_env_context_preserved() { + let mut sections = HashMap::new(); + sections.insert( + "environment_context".to_string(), + SectionOverride { + action: Some("replace".to_string()), + content: Some("custom env".to_string()), + }, + ); + let cfg = SystemMessageConfig { + mode: Some("customize".to_string()), + content: None, + sections: Some(sections), + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap(); + let env = out.sections.unwrap().remove("environment_context").unwrap(); + assert_eq!(env.action.as_deref(), Some("replace")); + assert_eq!(env.content.as_deref(), Some("custom env")); + } + + #[test] + fn system_message_empty_customize_without_env_context_gets_strip() { + let mut sections = HashMap::new(); + sections.insert( + "other_section".to_string(), + SectionOverride { + action: Some("replace".to_string()), + content: Some("body".to_string()), + }, + ); + let cfg = SystemMessageConfig { + mode: Some("customize".to_string()), + content: None, + sections: Some(sections), + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap(); + let secs = out.sections.unwrap(); + assert!(secs.contains_key("other_section")); + let env = secs.get("environment_context").unwrap(); + assert_eq!(env.action.as_deref(), Some("remove")); + } +} diff --git a/rust/src/session.rs b/rust/src/session.rs index d401a7d45..9e1f444d6 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -801,6 +801,29 @@ impl Client { if let Some(transforms) = config.system_message_transform.clone() { inject_transform_sections(&mut config, transforms.as_ref()); } + let mode = self.inner.mode; + if mode == crate::ClientMode::Empty && config.available_tools.is_none() { + return Err(Error::InvalidConfig( + "ClientMode::Empty requires available_tools to be set on the session config. \ + Use ToolSet to specify which tools the session may use (e.g. \ + ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED))." + .to_string(), + )); + } + crate::mode::validate_tool_filter_list( + "available_tools", + config.available_tools.as_deref(), + )?; + crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?; + config.system_message = + crate::mode::system_message_for_mode(mode, config.system_message.take()); + if mode == crate::ClientMode::Empty && config.enable_session_telemetry.is_none() { + config.enable_session_telemetry = Some(false); + } + let opt_skip_custom_instructions = config.skip_custom_instructions; + let opt_custom_agents_local_only = config.custom_agents_local_only; + let opt_coauthor_enabled = config.coauthor_enabled; + let opt_manage_schedule_enabled = config.manage_schedule_enabled; let (wire, mut runtime) = config.into_wire(session_id.clone())?; let permission_handler = crate::permission::resolve_handler( @@ -907,7 +930,7 @@ impl Client { "Client::create_session complete" ); registration.disarm(); - Ok(Session { + let session = Session { id: session_id, cwd: self.cwd().clone(), workspace_path: create_result.workspace_path, @@ -919,7 +942,17 @@ impl Client { capabilities, open_canvases: Arc::new(parking_lot::RwLock::new(Vec::new())), event_tx, - }) + }; + apply_mode_post_create_patch( + &session, + mode, + opt_skip_custom_instructions, + opt_custom_agents_local_only, + opt_coauthor_enabled, + opt_manage_schedule_enabled, + ) + .await?; + Ok(session) } /// Resume an existing session on the CLI. @@ -941,6 +974,29 @@ impl Client { if let Some(transforms) = config.system_message_transform.clone() { inject_transform_sections_resume(&mut config, transforms.as_ref()); } + let mode = self.inner.mode; + if mode == crate::ClientMode::Empty && config.available_tools.is_none() { + return Err(Error::InvalidConfig( + "ClientMode::Empty requires available_tools to be set on the session config. \ + Use ToolSet to specify which tools the session may use (e.g. \ + ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED))." + .to_string(), + )); + } + crate::mode::validate_tool_filter_list( + "available_tools", + config.available_tools.as_deref(), + )?; + crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?; + config.system_message = + crate::mode::system_message_for_mode(mode, config.system_message.take()); + if mode == crate::ClientMode::Empty && config.enable_session_telemetry.is_none() { + config.enable_session_telemetry = Some(false); + } + let opt_skip_custom_instructions = config.skip_custom_instructions; + let opt_custom_agents_local_only = config.custom_agents_local_only; + let opt_coauthor_enabled = config.coauthor_enabled; + let opt_manage_schedule_enabled = config.manage_schedule_enabled; let (wire, mut runtime) = config.into_wire()?; let permission_handler = crate::permission::resolve_handler( @@ -1080,7 +1136,7 @@ impl Client { "Client::resume_session complete" ); registration.disarm(); - Ok(Session { + let session = Session { id: session_id, cwd: self.cwd().clone(), workspace_path: resume_result.workspace_path, @@ -1092,12 +1148,69 @@ impl Client { capabilities, open_canvases, event_tx, - }) + }; + apply_mode_post_create_patch( + &session, + mode, + opt_skip_custom_instructions, + opt_custom_agents_local_only, + opt_coauthor_enabled, + opt_manage_schedule_enabled, + ) + .await?; + Ok(session) } } type CommandHandlerMap = HashMap>; +async fn apply_mode_post_create_patch( + session: &Session, + mode: crate::ClientMode, + opt_skip_custom_instructions: Option, + opt_custom_agents_local_only: Option, + opt_coauthor_enabled: Option, + opt_manage_schedule_enabled: Option, +) -> Result<(), Error> { + use crate::generated::api_types::SessionUpdateOptionsParams; + let mut patch = SessionUpdateOptionsParams::default(); + let should_send = if mode == crate::ClientMode::Empty { + patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true)); + patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true)); + patch.coauthor_enabled = Some(opt_coauthor_enabled.unwrap_or(false)); + patch.manage_schedule_enabled = Some(opt_manage_schedule_enabled.unwrap_or(false)); + patch.installed_plugins = Vec::new(); + true + } else { + let mut any = false; + if let Some(v) = opt_skip_custom_instructions { + patch.skip_custom_instructions = Some(v); + any = true; + } + if let Some(v) = opt_custom_agents_local_only { + patch.custom_agents_local_only = Some(v); + any = true; + } + if let Some(v) = opt_coauthor_enabled { + patch.coauthor_enabled = Some(v); + any = true; + } + if let Some(v) = opt_manage_schedule_enabled { + patch.manage_schedule_enabled = Some(v); + any = true; + } + any + }; + if !should_send { + return Ok(()); + } + if let Err(error) = session.rpc().options().update(patch).await { + let _ = session.disconnect().await; + return Err(error); + } + Ok(()) +} + fn build_command_handler_map(commands: Option<&[CommandDefinition]>) -> Arc { let map = match commands { Some(commands) => commands diff --git a/rust/src/types.rs b/rust/src/types.rs index 6f5c826c6..f9b29600d 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1234,6 +1234,22 @@ pub struct SessionConfig { /// `systemMessage.transform` RPC callbacks to it during the session. /// Use [`with_system_message_transform`](Self::with_system_message_transform) to install one. pub system_message_transform: Option>, + /// Whether to skip loading custom-instruction sources for this session. + /// Applied via `session.options.update` after create/resume. Defaults to + /// `true` in [`crate::ClientMode::Empty`] when unset. + pub skip_custom_instructions: Option, + /// Whether to constrain custom agents to local-only execution. Applied + /// via `session.options.update` after create/resume. Defaults to `true` + /// in [`crate::ClientMode::Empty`] when unset. + pub custom_agents_local_only: Option, + /// Whether to include the `Co-authored-by` trailer in commit messages. + /// Applied via `session.options.update` after create/resume. Defaults to + /// `false` in [`crate::ClientMode::Empty`] when unset. + pub coauthor_enabled: Option, + /// Whether to expose the `manage_schedule` tool. Applied via + /// `session.options.update` after create/resume. Defaults to `false` in + /// [`crate::ClientMode::Empty`] when unset. + pub manage_schedule_enabled: Option, } impl std::fmt::Debug for SessionConfig { @@ -1369,6 +1385,10 @@ impl Default for SessionConfig { hooks_handler: None, permission_policy: None, system_message_transform: None, + skip_custom_instructions: None, + custom_agents_local_only: None, + coauthor_enabled: None, + manage_schedule_enabled: None, } } } @@ -1456,6 +1476,7 @@ impl SessionConfig { extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, + tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, @@ -1837,6 +1858,30 @@ impl SessionConfig { self.cloud = Some(cloud); self } + + /// Set [`Self::skip_custom_instructions`]. + pub fn with_skip_custom_instructions(mut self, value: bool) -> Self { + self.skip_custom_instructions = Some(value); + self + } + + /// Set [`Self::custom_agents_local_only`]. + pub fn with_custom_agents_local_only(mut self, value: bool) -> Self { + self.custom_agents_local_only = Some(value); + self + } + + /// Set [`Self::coauthor_enabled`]. + pub fn with_coauthor_enabled(mut self, value: bool) -> Self { + self.coauthor_enabled = Some(value); + self + } + + /// Set [`Self::manage_schedule_enabled`]. + pub fn with_manage_schedule_enabled(mut self, value: bool) -> Self { + self.manage_schedule_enabled = Some(value); + self + } } /// Configuration for resuming an existing session via the `session.resume` RPC. @@ -1965,6 +2010,14 @@ pub struct ResumeSessionConfig { pub(crate) permission_policy: Option, /// System-message transform. See [`SessionConfig::system_message_transform`]. pub system_message_transform: Option>, + /// See [`SessionConfig::skip_custom_instructions`]. + pub skip_custom_instructions: Option, + /// See [`SessionConfig::custom_agents_local_only`]. + pub custom_agents_local_only: Option, + /// See [`SessionConfig::coauthor_enabled`]. + pub coauthor_enabled: Option, + /// See [`SessionConfig::manage_schedule_enabled`]. + pub manage_schedule_enabled: Option, } impl std::fmt::Debug for ResumeSessionConfig { @@ -2108,6 +2161,7 @@ impl ResumeSessionConfig { extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, + tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, @@ -2205,6 +2259,10 @@ impl ResumeSessionConfig { hooks_handler: None, permission_policy: None, system_message_transform: None, + skip_custom_instructions: None, + custom_agents_local_only: None, + coauthor_enabled: None, + manage_schedule_enabled: None, } } @@ -2531,6 +2589,30 @@ impl ResumeSessionConfig { self.continue_pending_work = Some(continue_pending); self } + + /// Set [`Self::skip_custom_instructions`]. + pub fn with_skip_custom_instructions(mut self, value: bool) -> Self { + self.skip_custom_instructions = Some(value); + self + } + + /// Set [`Self::custom_agents_local_only`]. + pub fn with_custom_agents_local_only(mut self, value: bool) -> Self { + self.custom_agents_local_only = Some(value); + self + } + + /// Set [`Self::coauthor_enabled`]. + pub fn with_coauthor_enabled(mut self, value: bool) -> Self { + self.coauthor_enabled = Some(value); + self + } + + /// Set [`Self::manage_schedule_enabled`]. + pub fn with_manage_schedule_enabled(mut self, value: bool) -> Self { + self.manage_schedule_enabled = Some(value); + self + } } /// Controls how the system message is constructed. diff --git a/rust/src/wire.rs b/rust/src/wire.rs index b97aea261..29f89d84d 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -67,6 +67,9 @@ pub(crate) struct SessionCreateWire { pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, + /// SDK always sends `"excluded"` so include + exclude lists compose + /// naturally (everything matching X except Y). + pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, pub env_value_mode: &'static str, @@ -143,6 +146,8 @@ pub(crate) struct SessionResumeWire { pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, + /// SDK always sends `"excluded"`. See create-wire docs. + pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, pub env_value_mode: &'static str, From 0bd8f727d11eb48f2d00138d8553c15f9afe4605 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 27 May 2026 16:22:23 +0100 Subject: [PATCH 11/11] E2E tests --- dotnet/test/Unit/ToolSetTests.cs | 128 ++++++++++ python/e2e/test_mode_empty_e2e.py | 237 +++++++++++++++++++ rust/tests/e2e.rs | 2 + rust/tests/e2e/mode_empty.rs | 378 ++++++++++++++++++++++++++++++ 4 files changed, 745 insertions(+) create mode 100644 dotnet/test/Unit/ToolSetTests.cs create mode 100644 python/e2e/test_mode_empty_e2e.py create mode 100644 rust/tests/e2e/mode_empty.rs diff --git a/dotnet/test/Unit/ToolSetTests.cs b/dotnet/test/Unit/ToolSetTests.cs new file mode 100644 index 000000000..a6990914e --- /dev/null +++ b/dotnet/test/Unit/ToolSetTests.cs @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System; +using System.IO; +using System.Linq; +using GitHub.Copilot; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +/// +/// Unit coverage for , , and +/// construction validation. +/// +/// Mirrors go/toolset_test.go, python/test_tool_set.py, and the +/// inline tests in rust/src/mode.rs. Private helpers +/// (ValidateToolFilterList, SystemMessageForMode, +/// ApplyConfigDefaultsForMode) are exercised by +/// dotnet/test/E2E/ModeEmptyE2ETests.cs. +/// +public class ToolSetTests +{ + [Fact] + public void ToolSet_Emits_Source_Qualified_Strings() + { + var items = new ToolSet() + .AddBuiltIn("bash") + .AddBuiltIn("*") + .AddCustom("my_tool") + .AddCustom("*") + .AddMcp("github-list_issues") + .AddMcp("*") + .ToList(); + + Assert.Equal( + new[] + { + "builtin:bash", + "builtin:*", + "custom:my_tool", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + }, + items); + } + + [Fact] + public void ToolSet_AddBuiltIn_Accepts_Enumerable() + { + var items = new ToolSet().AddBuiltIn(new[] { "bash", "view" }).ToList(); + Assert.Equal(new[] { "builtin:bash", "builtin:view" }, items); + } + + [Theory] + [InlineData("has:colon")] + [InlineData("has space")] + [InlineData("")] + public void ToolSet_Rejects_Invalid_Names(string bad) + { + Assert.Throws(() => new ToolSet().AddBuiltIn(bad)); + Assert.Throws(() => new ToolSet().AddCustom(bad)); + Assert.Throws(() => new ToolSet().AddMcp(bad)); + } + + [Fact] + public void ToolSet_Accepts_Wildcard() + { + var items = new ToolSet().AddBuiltIn("*").AddCustom("*").AddMcp("*").ToList(); + Assert.Equal(new[] { "builtin:*", "custom:*", "mcp:*" }, items); + } + + [Fact] + public void BuiltInTools_Isolated_Does_Not_Contain_Banned_Tools() + { + foreach (var banned in new[] { "bash", "powershell", "edit", "grep", "web_fetch" }) + { + Assert.DoesNotContain(banned, BuiltInTools.Isolated); + } + } + + [Fact] + public void BuiltInTools_Isolated_Contains_Expected_Tools() + { + foreach (var expected in new[] { "ask_user", "task_complete" }) + { + Assert.Contains(expected, BuiltInTools.Isolated); + } + } + + [Fact] + public void CopilotClient_Mode_Empty_Throws_Without_Base_Directory() + { + var ex = Assert.Throws(() => new CopilotClient(new CopilotClientOptions + { + Mode = CopilotClientMode.Empty, + })); + Assert.Contains("Empty", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CopilotClient_Mode_Empty_Accepts_Base_Directory() + { + var dir = Directory.CreateTempSubdirectory("copilot-empty-mode-test-").FullName; + try + { + using var client = new CopilotClient(new CopilotClientOptions + { + Mode = CopilotClientMode.Empty, + BaseDirectory = dir, + }); + Assert.Equal(CopilotClientMode.Empty, client.Options.Mode); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void CopilotClient_Default_Mode_Is_CopilotCli() + { + using var client = new CopilotClient(new CopilotClientOptions()); + Assert.Equal(CopilotClientMode.CopilotCli, client.Options.Mode); + } +} diff --git a/python/e2e/test_mode_empty_e2e.py b/python/e2e/test_mode_empty_e2e.py new file mode 100644 index 000000000..96173f11b --- /dev/null +++ b/python/e2e/test_mode_empty_e2e.py @@ -0,0 +1,237 @@ +""" +E2E coverage for ``mode="empty"`` + ``ToolSet`` patterns. + +The runtime is mode-agnostic — these tests verify the SDK's translation +reaches the runtime correctly by inspecting the resulting CapiProxy +chat-completion request (the LLM only sees tools the runtime exposed +for the session) and end-to-end behavior. + +Mirrors ``nodejs/test/e2e/mode_empty.e2e.test.ts`` and shares the same +recorded cassettes under ``test/snapshots/mode_empty/``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import sys +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio + +from copilot import BuiltInTools, CopilotClient, RuntimeConnection, ToolSet +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +@pytest_asyncio.fixture(scope="module", loop_scope="module") +async def ctx(request) -> AsyncIterator[E2ETestContext]: + """Module-scoped harness; we build a per-test empty-mode client below.""" + context = E2ETestContext() + await context.setup() + yield context + any_failed = request.session.stash.get("any_test_failed", False) + await context.teardown(test_failed=any_failed) + + +@contextlib.asynccontextmanager +async def empty_mode_client(ctx: E2ETestContext) -> AsyncIterator[CopilotClient]: + """Construct a Copilot client wired to the harness proxy in ``mode="empty"``.""" + client = CopilotClient( + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + args=(), + ), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token="fake-token-for-e2e-tests", + base_directory=ctx.home_dir, + mode="empty", + ) + try: + yield client + finally: + with contextlib.suppress(Exception): + await client.stop() + + +async def _tools_exposed_to_llm(ctx: E2ETestContext) -> list[str]: + exchanges = await ctx.wait_for_exchanges(minimum_count=1, timeout=10.0) + tools = exchanges[-1].get("request", {}).get("tools", []) or [] + return [ + t.get("function", {}).get("name") + for t in tools + if t.get("type") == "function" and t.get("function", {}).get("name") + ] + + +async def _system_message_to_llm(ctx: E2ETestContext) -> str: + exchanges = await ctx.wait_for_exchanges(minimum_count=1, timeout=10.0) + messages = exchanges[-1].get("request", {}).get("messages", []) or [] + for m in messages: + if m.get("role") == "system": + content = m.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and "text" in part + ) + return "" + + +def _shell_tool_name() -> str: + return "powershell" if sys.platform == "win32" else "bash" + + +class TestModeEmpty: + async def test_empty_mode_isolated_set_shell_tool_is_not_exposed( + self, ctx: E2ETestContext + ): + async with empty_mode_client(ctx) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_built_in(BuiltInTools.ISOLATED), + ) + try: + with contextlib.suppress(Exception): + await session.send(prompt="Say hi.") + # Give the agent a moment to issue the chat completion. + await asyncio.sleep(0.1) + + tool_names = await _tools_exposed_to_llm(ctx) + for banned in ("bash", "powershell", "edit", "grep", "web_fetch"): + assert banned not in tool_names, ( + f"isolated set must not expose {banned!r}, got {tool_names}" + ) + assert any(name in tool_names for name in BuiltInTools.ISOLATED), ( + f"expected at least one isolated tool to be registered, got {tool_names}" + ) + finally: + await session.disconnect() + + async def test_empty_mode_builtin_star_exposes_all_built_in_tools( + self, ctx: E2ETestContext + ): + async with empty_mode_client(ctx) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_built_in("*"), + ) + try: + with contextlib.suppress(Exception): + await session.send(prompt="Say hi.") + await asyncio.sleep(0.1) + + tool_names = await _tools_exposed_to_llm(ctx) + assert _shell_tool_name() in tool_names, ( + f"builtin:* should expose the shell tool, got {tool_names}" + ) + finally: + await session.disconnect() + + async def test_empty_mode_excluded_tools_subtracts_from_available_tools( + self, ctx: E2ETestContext + ): + shell = _shell_tool_name() + async with empty_mode_client(ctx) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_built_in("*"), + excluded_tools=[f"builtin:{shell}"], + ) + try: + with contextlib.suppress(Exception): + await session.send(prompt="Say hi.") + await asyncio.sleep(0.1) + + tool_names = await _tools_exposed_to_llm(ctx) + assert shell not in tool_names, ( + f"excluded shell must not be exposed, got {tool_names}" + ) + assert len(tool_names) > 0 + finally: + await session.disconnect() + + async def test_empty_mode_strips_environment_context_from_the_system_message_by_default( + self, ctx: E2ETestContext + ): + async with empty_mode_client(ctx) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_built_in(BuiltInTools.ISOLATED), + system_message={ + "mode": "customize", + "content": ( + "If the user asks you to name an element, reply with exactly " + "the single word ARGON in all caps and nothing else." + ), + }, + ) + try: + reply = await session.send_and_wait(prompt="Name an element.") + assert reply is not None + assert "ARGON" in reply.data.content + + system_message = await _system_message_to_llm(ctx) + assert "Current working directory:" not in system_message + assert "Operating System:" not in system_message + finally: + await session.disconnect() + + async def test_empty_mode_system_message_replace_llm_follows_caller_content_verbatim( + self, ctx: E2ETestContext + ): + async with empty_mode_client(ctx) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_built_in(BuiltInTools.ISOLATED), + system_message={ + "mode": "replace", + "content": ( + "You are a test fixture. Whenever the user asks anything, " + "reply with exactly the single word KRYPTON in all caps " + "and nothing else." + ), + }, + ) + try: + reply = await session.send_and_wait(prompt="Hello.") + assert reply is not None + assert "KRYPTON" in reply.data.content + finally: + await session.disconnect() + + async def test_empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped( + self, ctx: E2ETestContext + ): + async with empty_mode_client(ctx) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_built_in(BuiltInTools.ISOLATED), + system_message={ + "mode": "append", + "content": ( + "If the user asks you to name a noble gas, reply with exactly " + "the single word XENON in all caps and nothing else." + ), + }, + ) + try: + reply = await session.send_and_wait(prompt="Name a noble gas.") + assert reply is not None + assert "XENON" in reply.data.content + + system_message = await _system_message_to_llm(ctx) + assert "Current working directory:" not in system_message + assert "Operating System:" not in system_message + finally: + await session.disconnect() diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 8589bca47..b24a647cd 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -33,6 +33,8 @@ mod hooks; mod hooks_extended; #[path = "e2e/mcp_and_agents.rs"] mod mcp_and_agents; +#[path = "e2e/mode_empty.rs"] +mod mode_empty; #[path = "e2e/mode_handlers.rs"] mod mode_handlers; #[path = "e2e/multi_client.rs"] diff --git a/rust/tests/e2e/mode_empty.rs b/rust/tests/e2e/mode_empty.rs new file mode 100644 index 000000000..9e3e72de5 --- /dev/null +++ b/rust/tests/e2e/mode_empty.rs @@ -0,0 +1,378 @@ +//! E2E coverage for `ClientMode::Empty` + `ToolSet` patterns. +//! +//! The runtime is mode-agnostic — these tests verify the SDK's +//! translation reaches the runtime correctly by inspecting the +//! resulting CapiProxy chat-completion request (the LLM only sees +//! tools the runtime exposed for the session) and end-to-end behavior. +//! +//! Mirrors `nodejs/test/e2e/mode_empty.e2e.test.ts` and shares the +//! same recorded cassettes under `test/snapshots/mode_empty/`. + +use std::sync::Arc; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::types::SystemMessageConfig; +use github_copilot_sdk::{ + BUILTIN_TOOLS_ISOLATED, Client, ClientMode, SessionConfig, ToolSet, +}; +use serde_json::Value; + +use super::support::{assistant_message_content, with_e2e_context}; + +const SHELL_TOOL_NAME: &str = if cfg!(windows) { "powershell" } else { "bash" }; + +fn isolated_tool_set() -> Vec { + ToolSet::new() + .add_builtin_many(BUILTIN_TOOLS_ISOLATED.iter().copied()) + .expect("isolated tool set should be valid") + .into() +} + +fn star_builtin_tool_set() -> Vec { + ToolSet::new() + .add_builtin("*") + .expect("builtin wildcard should be valid") + .into() +} + +fn tool_names_from_request(exchange: &Value) -> Vec { + let Some(tools) = exchange + .get("request") + .and_then(|r| r.get("tools")) + .and_then(|t| t.as_array()) + else { + return Vec::new(); + }; + tools + .iter() + .filter_map(|t| { + let type_ok = t.get("type").and_then(Value::as_str) == Some("function"); + if !type_ok { + return None; + } + t.get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() +} + +fn system_message_from_request(exchange: &Value) -> String { + let Some(messages) = exchange + .get("request") + .and_then(|r| r.get("messages")) + .and_then(|m| m.as_array()) + else { + return String::new(); + }; + for m in messages { + if m.get("role").and_then(Value::as_str) != Some("system") { + continue; + } + let content = m.get("content"); + if let Some(text) = content.and_then(Value::as_str) { + return text.to_owned(); + } + if let Some(parts) = content.and_then(Value::as_array) { + return parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + } + } + String::new() +} + +#[tokio::test] +async fn empty_mode_isolated_set_shell_tool_is_not_exposed() { + with_e2e_context( + "mode_empty", + "empty_mode_isolated_set_shell_tool_is_not_exposed", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()), + ) + .await + .expect("create session"); + + let _ = session.send_and_wait("Say hi.").await; + + let exchanges = ctx.exchanges(); + assert!(!exchanges.is_empty(), "expected at least one exchange"); + let tool_names = tool_names_from_request(exchanges.last().unwrap()); + for banned in ["bash", "powershell", "edit", "grep", "web_fetch"] { + assert!( + !tool_names.iter().any(|n| n == banned), + "isolated set must not expose {banned:?}, got {tool_names:?}" + ); + } + let any_isolated = BUILTIN_TOOLS_ISOLATED + .iter() + .any(|n| tool_names.iter().any(|t| t == n)); + assert!( + any_isolated, + "expected at least one isolated tool to be registered, got {tool_names:?}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_builtin_star_exposes_all_built_in_tools() { + with_e2e_context( + "mode_empty", + "empty_mode_builtin_star_exposes_all_built_in_tools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(star_builtin_tool_set()), + ) + .await + .expect("create session"); + + let _ = session.send_and_wait("Say hi.").await; + + let exchanges = ctx.exchanges(); + let tool_names = tool_names_from_request(exchanges.last().unwrap()); + assert!( + tool_names.iter().any(|n| n == SHELL_TOOL_NAME), + "builtin:* should expose {SHELL_TOOL_NAME}, got {tool_names:?}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_excluded_tools_subtracts_from_available_tools() { + with_e2e_context( + "mode_empty", + "empty_mode_excluded_tools_subtracts_from_available_tools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(star_builtin_tool_set()) + .with_excluded_tools(vec![format!("builtin:{SHELL_TOOL_NAME}")]), + ) + .await + .expect("create session"); + + let _ = session.send_and_wait("Say hi.").await; + + let exchanges = ctx.exchanges(); + let tool_names = tool_names_from_request(exchanges.last().unwrap()); + assert!( + !tool_names.iter().any(|n| n == SHELL_TOOL_NAME), + "excluded {SHELL_TOOL_NAME} must not be exposed, got {tool_names:?}" + ); + assert!(!tool_names.is_empty()); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_strips_environment_context_from_the_system_message_by_default() { + with_e2e_context( + "mode_empty", + "empty_mode_strips_environment_context_from_the_system_message_by_default", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()) + .with_system_message( + SystemMessageConfig::new() + .with_mode("customize") + .with_content( + "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + ), + ), + ) + .await + .expect("create session"); + + let event = session + .send_and_wait("Name an element.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&event); + assert!(content.contains("ARGON"), "expected ARGON in reply, got {content:?}"); + + let exchanges = ctx.exchanges(); + let system_message = system_message_from_request(exchanges.last().unwrap()); + assert!( + !system_message.to_lowercase().contains("current working directory:"), + "env context should be stripped, got: {system_message}" + ); + assert!( + !system_message.to_lowercase().contains("operating system:"), + "env context should be stripped, got: {system_message}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_system_message_replace_llm_follows_caller_content_verbatim() { + with_e2e_context( + "mode_empty", + "empty_mode_system_message_replace_llm_follows_caller_content_verbatim", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()) + .with_system_message( + SystemMessageConfig::new() + .with_mode("replace") + .with_content( + "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + ), + ), + ) + .await + .expect("create session"); + + let event = session + .send_and_wait("Hello.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&event); + assert!(content.contains("KRYPTON"), "expected KRYPTON in reply, got {content:?}"); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped() { + with_e2e_context( + "mode_empty", + "empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()) + .with_system_message( + SystemMessageConfig::new() + .with_mode("append") + .with_content( + "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + ), + ), + ) + .await + .expect("create session"); + + let event = session + .send_and_wait("Name a noble gas.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&event); + assert!(content.contains("XENON"), "expected XENON in reply, got {content:?}"); + + let exchanges = ctx.exchanges(); + let system_message = system_message_from_request(exchanges.last().unwrap()); + assert!( + !system_message.to_lowercase().contains("current working directory:"), + "env context should be stripped, got: {system_message}" + ); + assert!( + !system_message.to_lowercase().contains("operating system:"), + "env context should be stripped, got: {system_message}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +}