Skip to content

Commit d166a45

Browse files
Steve SandersonCopilot
authored andcommitted
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>
1 parent c69ea43 commit d166a45

9 files changed

Lines changed: 670 additions & 9 deletions

nodejs/src/client.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
4141
import { CopilotSession } from "./session.js";
4242
import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js";
4343
import { getTraceContext } from "./telemetry.js";
44+
import { ToolSet } from "./toolSet.js";
4445
import type {
4546
AutoModeSwitchRequest,
4647
AutoModeSwitchResponse,
48+
CopilotClientMode,
4749
CopilotClientOptions,
4850
CustomAgentConfig,
4951
ExitPlanModeRequest,
@@ -134,6 +136,38 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[]
134136
});
135137
}
136138

139+
function toolFilterListToArray(value: string[] | ToolSet | undefined): string[] | undefined {
140+
if (value === undefined) {
141+
return undefined;
142+
}
143+
return value instanceof ToolSet ? value.toArray() : value;
144+
}
145+
146+
/**
147+
* Catches misuse of `availableTools`/`excludedTools` at the SDK boundary so
148+
* users get an actionable error rather than a silently-empty filter.
149+
*
150+
* The runtime treats a bare `"*"` as a literal name match for a tool whose
151+
* name is the single character `*`, which the runtime's charset guard would
152+
* reject at registration — so the filter effectively matches nothing. We
153+
* surface that here as an error pointing the developer at the source-qualified
154+
* forms produced by {@link ToolSet}.
155+
*/
156+
function validateToolFilterList(field: string, list: string[] | undefined): void {
157+
if (!list) {
158+
return;
159+
}
160+
for (const entry of list) {
161+
if (entry === "*") {
162+
throw new Error(
163+
`Invalid ${field} entry '*': there is no bare wildcard. ` +
164+
"Use one or more of `new ToolSet().addBuiltIn('*')`, `.addMcp('*')`, " +
165+
"or `.addCustom('*')` to target a specific source."
166+
);
167+
}
168+
}
169+
}
170+
137171
function isCanvasProviderRequestParams(params: unknown): params is CanvasProviderRequestParams {
138172
if (!params || typeof params !== "object") {
139173
return false;
@@ -298,6 +332,7 @@ export class CopilotClient {
298332
baseDirectory?: string;
299333
sessionIdleTimeoutSeconds: number;
300334
enableRemoteSessions: boolean;
335+
mode: CopilotClientMode;
301336
};
302337
private isExternalServer: boolean = false;
303338
private forceStopping: boolean = false;
@@ -445,7 +480,29 @@ export class CopilotClient {
445480
baseDirectory: options.baseDirectory,
446481
sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0,
447482
enableRemoteSessions: options.enableRemoteSessions ?? false,
483+
mode: options.mode ?? "copilot-cli",
448484
};
485+
486+
// Empty mode: validate at construction time that the app supplied a
487+
// per-session persistence location. The runtime is mode-agnostic, so
488+
// without this check it would silently fall back to ~/.copilot, which
489+
// defeats the point of empty mode for multi-tenant scenarios.
490+
if (this.options.mode === "empty") {
491+
const hasPersistence =
492+
this.options.baseDirectory !== undefined ||
493+
this.sessionFsConfig !== null ||
494+
// External runtimes manage their own persistence layer; the SDK
495+
// can't enforce it from here.
496+
conn.kind === "uri" ||
497+
conn.kind === "parent-process";
498+
if (!hasPersistence) {
499+
throw new Error(
500+
"CopilotClient was created with mode: 'empty' but neither " +
501+
"'baseDirectory' nor 'sessionFs' was set. Empty mode requires " +
502+
"an explicit per-session persistence location; pick one."
503+
);
504+
}
505+
}
449506
}
450507

451508
private connectionExtraArgs: string[] = [];
@@ -820,6 +877,48 @@ export class CopilotClient {
820877
* });
821878
* ```
822879
*/
880+
/**
881+
* Normalizes session-level tool filter options. Converts {@link ToolSet}
882+
* instances to plain string arrays, rejects misuse (bare `"*"`) and the
883+
* missing-availableTools case in `mode = "empty"`, and applies the
884+
* mode-aware default for `toolFilterMode`.
885+
*
886+
* @internal
887+
*/
888+
private resolveToolFilterOptions(config: {
889+
availableTools?: string[] | ToolSet;
890+
excludedTools?: string[] | ToolSet;
891+
toolFilterMode?: "allowPrecedence" | "denyPrecedence";
892+
}): {
893+
availableTools: string[] | undefined;
894+
excludedTools: string[] | undefined;
895+
toolFilterMode: "allowPrecedence" | "denyPrecedence" | undefined;
896+
} {
897+
const availableTools = toolFilterListToArray(config.availableTools);
898+
const excludedTools = toolFilterListToArray(config.excludedTools);
899+
validateToolFilterList("availableTools", availableTools);
900+
validateToolFilterList("excludedTools", excludedTools);
901+
902+
if (this.options.mode === "empty") {
903+
if (availableTools === undefined) {
904+
throw new Error(
905+
"CopilotClient is in mode: 'empty' but the session config did not " +
906+
"specify 'availableTools'. Empty mode requires every session to " +
907+
"explicitly opt into the tools it wants — e.g. " +
908+
"`new ToolSet().addBuiltIn(BuiltInTools.Isolated)`."
909+
);
910+
}
911+
}
912+
913+
// Empty mode flips the default to deny-precedence so apps can compose
914+
// include + exclude lists naturally (e.g. "everything matching X
915+
// except Y"). Callers can still override this explicitly.
916+
const toolFilterMode =
917+
config.toolFilterMode ?? (this.options.mode === "empty" ? "denyPrecedence" : undefined);
918+
919+
return { availableTools, excludedTools, toolFilterMode };
920+
}
921+
823922
async createSession(config: SessionConfig): Promise<CopilotSession> {
824923
if (!this.connection) {
825924
await this.start();
@@ -869,6 +968,8 @@ export class CopilotClient {
869968
this.sessions.set(sessionId, session);
870969
this.setupSessionFs(session, config);
871970

971+
const toolFilterOptions = this.resolveToolFilterOptions(config);
972+
872973
try {
873974
const response = await this.connection!.sendRequest("session.create", {
874975
...(await getTraceContext(this.onGetTraceContext)),
@@ -892,8 +993,9 @@ export class CopilotClient {
892993
description: cmd.description,
893994
})),
894995
systemMessage: wireSystemMessage,
895-
availableTools: config.availableTools,
896-
excludedTools: config.excludedTools,
996+
availableTools: toolFilterOptions.availableTools,
997+
excludedTools: toolFilterOptions.excludedTools,
998+
toolFilterMode: toolFilterOptions.toolFilterMode,
897999
provider: config.provider,
8981000
enableSessionTelemetry: config.enableSessionTelemetry,
8991001
modelCapabilities: config.modelCapabilities,
@@ -1008,6 +1110,8 @@ export class CopilotClient {
10081110
this.sessions.set(sessionId, session);
10091111
this.setupSessionFs(session, config);
10101112

1113+
const toolFilterOptions = this.resolveToolFilterOptions(config);
1114+
10111115
try {
10121116
const response = await this.connection!.sendRequest("session.resume", {
10131117
...(await getTraceContext(this.onGetTraceContext)),
@@ -1016,8 +1120,9 @@ export class CopilotClient {
10161120
model: config.model,
10171121
reasoningEffort: config.reasoningEffort,
10181122
systemMessage: wireSystemMessage,
1019-
availableTools: config.availableTools,
1020-
excludedTools: config.excludedTools,
1123+
availableTools: toolFilterOptions.availableTools,
1124+
excludedTools: toolFilterOptions.excludedTools,
1125+
toolFilterMode: toolFilterOptions.toolFilterMode,
10211126
enableSessionTelemetry: config.enableSessionTelemetry,
10221127
tools: config.tools?.map((tool) => ({
10231128
name: tool.name,

nodejs/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
export { CopilotClient } from "./client.js";
1212
export { RuntimeConnection } from "./types.js";
13+
export { BuiltInTools, ToolSet } from "./toolSet.js";
1314
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
1415
export {
1516
Canvas,
@@ -54,6 +55,7 @@ export type {
5455
AutoModeSwitchHandler,
5556
AutoModeSwitchRequest,
5657
AutoModeSwitchResponse,
58+
CopilotClientMode,
5759
CopilotClientOptions,
5860
StdioRuntimeConnection,
5961
TcpRuntimeConnection,

nodejs/src/toolSet.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
/**
6+
* Builder for the {@link SessionConfigBase.availableTools} list using
7+
* source-qualified filter patterns (`builtin:*`, `mcp:<name>`, `custom:*`, etc.).
8+
*
9+
* See plan: client-level Mode = "empty" with explicit tool selection.
10+
*/
11+
12+
/**
13+
* Tool name character set enforced by the runtime at every registration
14+
* boundary. Mirrors the runtime's `VALID_TOOL_NAME_REGEX`. Used to validate
15+
* names passed to the `ToolSet` builder so misuse is caught at the SDK
16+
* boundary with a better error than the runtime would produce.
17+
*/
18+
const VALID_TOOL_NAME = /^[a-zA-Z0-9_-]+$/;
19+
20+
function validateName(kind: "builtin" | "mcp" | "custom", name: string): void {
21+
if (name === "*") {
22+
return;
23+
}
24+
if (!VALID_TOOL_NAME.test(name)) {
25+
throw new Error(
26+
`Invalid ${kind} tool name '${name}': tool names must match /^[a-zA-Z0-9_-]+$/ ` +
27+
`or be the wildcard '*'.`
28+
);
29+
}
30+
}
31+
32+
/**
33+
* Builder that produces a list of source-qualified tool filter strings for
34+
* {@link SessionConfigBase.availableTools}.
35+
*
36+
* Tools are classified by the runtime at registration time (not from name
37+
* parsing), so `addBuiltIn("foo")` matches only tools the runtime registered
38+
* as built-in, even if an MCP server or custom-agent extension happens to
39+
* register a tool with the same wire name.
40+
*
41+
* @example
42+
* ```typescript
43+
* const tools = new ToolSet()
44+
* .addBuiltIn(BuiltInTools.Isolated)
45+
* .addMcp("*")
46+
* .addCustom("*");
47+
*
48+
* const session = await client.createSession({
49+
* availableTools: tools,
50+
* // ...
51+
* });
52+
* ```
53+
*/
54+
export class ToolSet {
55+
private readonly items: string[] = [];
56+
57+
/**
58+
* Adds one or more built-in tool patterns.
59+
*
60+
* @param name A specific built-in tool name (e.g. `"bash"`) or `"*"` to match all
61+
* built-in tools.
62+
*/
63+
addBuiltIn(name: string): ToolSet;
64+
/**
65+
* Adds a list of built-in tool patterns (e.g. {@link BuiltInTools.Isolated}).
66+
*/
67+
addBuiltIn(names: readonly string[]): ToolSet;
68+
addBuiltIn(nameOrNames: string | readonly string[]): ToolSet {
69+
const names = typeof nameOrNames === "string" ? [nameOrNames] : nameOrNames;
70+
for (const name of names) {
71+
validateName("builtin", name);
72+
this.items.push(`builtin:${name}`);
73+
}
74+
return this;
75+
}
76+
77+
/**
78+
* Adds a custom tool pattern. Matches tools registered via the SDK's
79+
* `tools` option or via custom agents.
80+
*
81+
* @param name A specific custom tool name or `"*"` to match all custom tools.
82+
*/
83+
addCustom(name: string): ToolSet {
84+
validateName("custom", name);
85+
this.items.push(`custom:${name}`);
86+
return this;
87+
}
88+
89+
/**
90+
* Adds an MCP tool pattern. Matches tools advertised by any configured
91+
* MCP server.
92+
*
93+
* @param toolName The runtime's canonical wire name for the MCP tool
94+
* (e.g. `"github-list_issues"`), or `"*"` to match all MCP tools from
95+
* any server.
96+
*/
97+
addMcp(toolName: string): ToolSet {
98+
validateName("mcp", toolName);
99+
this.items.push(`mcp:${toolName}`);
100+
return this;
101+
}
102+
103+
/**
104+
* Returns a defensive copy of the accumulated filter strings, suitable for
105+
* passing as {@link SessionConfigBase.availableTools}.
106+
*/
107+
toArray(): string[] {
108+
return [...this.items];
109+
}
110+
}
111+
112+
/**
113+
* Curated sets of built-in tool names for common scenarios. Each constant is
114+
* meant to be passed to {@link ToolSet.addBuiltIn}.
115+
*/
116+
export const BuiltInTools = {
117+
/**
118+
* Built-in tools that operate only within the bounds of a single session —
119+
* no host filesystem access outside the session, no cross-session state,
120+
* no host environment access, no network. Safe to enable in `Mode = "empty"`
121+
* scenarios (e.g. multi-tenant servers) without leaking host capabilities.
122+
*
123+
* **Contract:** tools in this set MUST NOT be extended (even behind options
124+
* or args) to read or write state outside the session boundary. Adding
125+
* cross-session or host-state behavior to one of these tools is a
126+
* breaking change that requires removing it from this set.
127+
*/
128+
Isolated: [
129+
"ask_user",
130+
"task_complete",
131+
"exit_plan_mode",
132+
"task",
133+
"read_agent",
134+
"write_agent",
135+
"list_agents",
136+
"send_inbox",
137+
"context_board",
138+
"skill",
139+
] as readonly string[],
140+
} as const;

0 commit comments

Comments
 (0)