Summary
The GitHub Copilot CLI SDK chat agent path (CopilotSdkChatClient — the Copilot-SDK-backed chat client wired by the github-copilot and github-copilot-subagent providers in AgentFactory) currently has no mechanism to control which Copilot CLI built-in tools an agent gets. Every session created through BuildSessionConfig / BuildResumeSessionConfig inherits the SDK's default tool set (all built-ins). Restricted or safety-sensitive agents (sandboxed sub-agents, review-only agents, agents that should route exclusively to user-supplied MCP tools) cannot opt out, and there is no way to whitelist just the isolated set or to exclude specific tools.
This bug adds a github-cli-builtin-tools CustomTool entry on the AgentDefinition whose options object exposes:
- One reusable tool-set selector applied to two independent slots —
available-tools and excluded-tools — mapping natively onto SessionConfig.AvailableTools and SessionConfig.ExcludedTools on the pinned GitHub.Copilot.SDK 1.0.8. Selector entries accept bare built-in names (auto-prefixed to builtin:<name>) as well as explicit source-qualified patterns such as mcp:*, custom:*, builtin:*, or mcp:<wire-name> — which flow through to the SDK verbatim.
- A
client-mode field opting an agent into the SDK's CopilotClientMode.Empty (a client-construction-time property, NOT a session field). This is the canonical way to build "locked-down" agents that expose nothing by default and route only to what available-tools explicitly allows — see the worked "MCP-only" recipe below.
The reusable selector — three forms (post-simplification)
A BuiltinToolSet selector is a small JSON object with one of these shapes:
| # |
Form |
Meaning |
| 1 |
{ "tools": ["*"] } |
The entire built-in tool set — the wildcard "*" is a normal member of tools |
| 2 |
{ "tools": ["tool1","tool2"] } |
Exactly the named tools |
| 3 |
{ "isolated": true } |
The isolated built-in tool set (GitHub.Copilot.BuiltInTools.Isolated) |
| 4 |
{ "tools": [] } |
The empty set (no tools) |
The selector is essentially { tools: string[] } OR { isolated: true }. There is no longer a dedicated all boolean — the wildcard "*" collapses into the tools list and covers form #1 as an ordinary token. Nullability of the deserialised Tools property (see BuiltinToolSet record below) distinguishes form #4 (Tools = []) from form-#3-with-Tools-absent (Tools = null, Isolated = true).
Two slots — same selector shape applied to both
The github-cli-builtin-tools tool's options object has two optional slots, each of which — when present — is a BuiltinToolSet selector:
Owner-supplied examples showing the wildcard:
Both slots may be present simultaneously. Absent slots leave the corresponding SDK field untouched (SDK default). Omitting the github-cli-builtin-tools entry entirely leaves both fields untouched, i.e. SDK default = all built-ins.
Precedence within a selector
Highest wins:
tools (any list, including []) — most specific.
isolated: true.
- Empty selector object / neither key present ⇒ rejected by validation (see "Validation" section below). Under the earlier design this was a logged warning.
The factory logs a warning if it detects both tools and isolated on the same selector, and applies tools > isolated.
Combined-slot semantics
Both slots are independent SDK fields on the underlying SessionConfig. The SDK's own filter applies AvailableTools (allow-list) first, then removes anything named in ExcludedTools. Documented equivalent behaviour: final built-in tool set = AvailableTools \ ExcludedTools (AND semantics).
Direct answers to owner questions
- "Is there no such capability in the C# SDK?" — Yes there is. The pinned
GitHub.Copilot.SDK 1.0.8 exposes:
SessionConfig.AvailableTools : IList<string> — allow-list of source-qualified tool names. null (unset) ⇒ SDK default (no filter). Empty list ⇒ no tools included.
SessionConfig.ExcludedTools : IList<string> — deny-list of source-qualified tool names.
GitHub.Copilot.BuiltInTools.Isolated : IReadOnlyList<string> — the canonical "isolated" built-in tool set. Observed values in 1.0.8: ask_user, task_complete, exit_plan_mode, task, read_agent, write_agent, list_agents, send_inbox, context_board, skill (10 entries).
GitHub.Copilot.ToolSet — a List<string>-like builder with AddBuiltIn(string), AddBuiltIn(IEnumerable<string>), AddMcp(string), AddCustom(string). Each method prepends a source prefix to the tool name: AddBuiltIn("shell") → "builtin:shell", AddBuiltIn("*") → "builtin:*", AddMcp("*") → "mcp:*".
ResumeSessionConfig exposes the identical AvailableTools / ExcludedTools / ExcludedBuiltInAgents properties.
- "Is it possible we need a more recent version?" — No.
Directory.Packages.props pins GitHub.Copilot.SDK 1.0.8, which already contains all of the above. No package bump required.
- Does the SDK special-case the
"*" wildcard? — Yes, but only in source-qualified form, i.e. "builtin:*" / "mcp:*" / "custom:*". A bare "*" in AvailableTools/ExcludedTools is explicitly rejected by the SDK: verified from a string literal embedded in GitHub.Copilot.SDK.dll 1.0.8:
entry '*': there is no bare wildcard. Use \new ToolSet().AddBuiltIn("")`, `.AddMcp("")`, or `.AddCustom("")` to target a specific source.Confidence: **high** (message extracted from the shipping assembly;ToolSet.AddBuiltIn("")was executed and observed to produce"builtin:"). The owner-facing JSON schema still uses the ergonomic bare ""; **AgentFactorytransparently rewrites""→"builtin:"and every other name →"builtin:"when populating the SDK fields** (this tool kind's entire purpose is built-in tool control, so thebuiltin:prefix is applied uniformly). Equivalently,AgentFactorymay build the list vianew ToolSet().AddBuiltIn(name)` per name — same result.
Root Cause / Current State
1. Session configuration only adds user tools; it never gates SDK built-in defaults.
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs:
BuildSessionConfig (~L260–306):
var sessionConfig = new SessionConfig
{
Model = effectiveModelId,
Streaming = true,
OnPermissionRequest = PermissionHandler.ApproveAll, // ~L274
};
...
var tools = options?.Tools?.OfType<AIFunctionDeclaration>().ToList(); // ~L299
if (tools is { Count: > 0 })
{
sessionConfig.Tools = tools; // ~L302
}
BuildResumeSessionConfig (~L314–360): same shape.
Neither method ever sets AvailableTools / ExcludedTools, so the SDK falls back to its full default built-in set.
2. AgentFactory never reads a tool-policy entry from the AgentDefinition.
Phantom.Workspaces.Llm.Core/AgentFactory.cs:
CreateGitHubCopilotClient (~L798–841) constructs new CopilotSdkChatClient(modelId, displayName, gitHubToken, ...) at ~L829 with no tool-policy parameter.
CreateGitHubCopilotByokClient (~L847–895) — constructor call at ~L883.
- Additional
new CopilotSdkChatClient(...) sites at ~L933 and ~L989 are identical.
ExtractTools (~L148–151) returns (agent as PromptAgent)?.Tools ?? [] with no type filtering; entries flow into ToolsetFactory for MCP/agent-definition handling and are not forwarded to CopilotSdkChatClient.
3. AgentDefinition JSON schema — the generic tool subschema already accepts arbitrary properties.
Phantom.Workspaces.Llm.Core/JsonSchemas/AgentDefinition.json:
tools[] (~L129–143) is an anyOf union of mcpTool / tool / agentDefinitionTool.
- The generic
$defs/tool subschema (~L151–172) sets additionalProperties: true (~L153), which is why an unrecognised kind currently validates trivially.
allowedTools (~L269–274) lives inside mcpTool and gates MCP-server tools; it does not affect Copilot CLI built-in tools.
alwaysRequireApprovalTools / neverRequireApprovalTools only tune approval prompts for tools already exposed.
This design adds a dedicated $defs/githubCliBuiltinToolsTool subschema with kind: { const: "github-cli-builtin-tools" } and fully-typed available-tools, excluded-tools, and client-mode properties, and inserts it into tools[]'s anyOf before the generic $defs/tool. To keep the shape strict (so a malformed github-cli-builtin-tools entry cannot silently fall through to the permissive generic tool), the generic $defs/tool is amended to exclude this specific kind — see "Schema-level enforcement" below. This is the owner-preferred approach: the schema is the authoritative shape gate, so an incorrectly-generated definition fails at load/validate time rather than at first session build.
4. Copilot SDK 1.0.8 surface — verified by reflection and DLL string extraction.
Verified by loading %USERPROFILE%\.nuget\packages\github.copilot.sdk\1.0.8\lib\net8.0\GitHub.Copilot.SDK.dll. On both GitHub.Copilot.SessionConfig and GitHub.Copilot.ResumeSessionConfig:
| Member |
Type |
Role |
Tools |
ICollection<Microsoft.Extensions.AI.AIFunctionDeclaration> |
User-supplied custom tools (already used today) |
AvailableTools |
IList<string> |
Allow-list of source-qualified tool names. null (unset) ⇒ no filter (SDK default). Empty list ⇒ no tools. Bare "*" is rejected. |
ExcludedTools |
IList<string> |
Deny-list of source-qualified tool names. null (unset) ⇒ no exclusion. |
ExcludedBuiltInAgents |
IList<string> |
Deny-list of built-in sub-agent names (background only) |
OnPermissionRequest |
Func<PermissionRequest, ..., PermissionDecision> |
Per-invocation permission gate |
Public GitHub.Copilot.BuiltInTools (static class): sole public static member is Isolated : IReadOnlyList<string> (10 entries: ask_user, task_complete, exit_plan_mode, task, read_agent, write_agent, list_agents, send_inbox, context_board, skill). There is no All / Default / Names enumeration — this does not matter for the current design, because the wildcard "builtin:*" covers the "everything" case natively via ExcludedTools.
Public GitHub.Copilot.ToolSet (implements IList<string>): constructor .ctor(), methods AddBuiltIn(string) → ToolSet, AddBuiltIn(IEnumerable<string>) → ToolSet, AddMcp(string) → ToolSet, AddCustom(string) → ToolSet. Runtime observation: new ToolSet().AddBuiltIn("*") yields the single element "builtin:*"; AddBuiltIn(BuiltInTools.Isolated) yields builtin:ask_user, builtin:task_complete, …. String literal embedded in the DLL:
entry '*': there is no bare wildcard. Use \new ToolSet().AddBuiltIn("")`, `.AddMcp("")`, or `.AddCustom("*")` to target a specific source.`
Also embedded (Empty-mode diagnostic):
Empty mode requires every session to explicitly opt into the tools it wants — e.g. \AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated)`.`
Together these confirm: (a) the SDK does natively understand "*" as a wildcard, but only source-qualified; (b) ToolSet is the recommended builder for the source-prefixed strings that AvailableTools/ExcludedTools accept.
5. AgentSchema.CustomTool deserialisation — typed subclass, not the open Options bag.
AgentSchema.CustomTool currently exposes Kind, Name, Description, ShorthandProperty, Connection, IList<Binding> Bindings, and an open IDictionary<string, object> Options bag (precedent: the chat-history CustomTool at AgentFactory.cs:581–586 reads Options["connection"] is IDictionary<string, object> connectionDict).
For this feature we prefer typed JSON deserialisation over the open Options bag (owner directive — the policy shape must be a first-class deserialisable type, not object fished out of a dictionary). Concretely: introduce a GitHubCliBuiltinToolsTool subclass of CustomTool with typed properties AvailableTools : BuiltinToolSet?, ExcludedTools : BuiltinToolSet?, ClientMode : CopilotClientMode, deserialised via a kind-discriminated System.Text.Json polymorphic converter on CustomTool (or, equivalently, an AgentSchema.ToolConverter that dispatches on kind to the right subclass and falls through to the base CustomTool for unknown kinds). BuiltinToolSet is itself a typed record — see the model section below — with [JsonPropertyName("tools")] IReadOnlyList<string>? Tools and [JsonPropertyName("isolated")] bool Isolated.
This has several concrete benefits:
- The policy record is JSON-deserialisable end-to-end — no runtime unboxing of
IDictionary<string, object> / IEnumerable<object>, no is IDictionary<string, object> checks, no .OfType<string>().ToArray() gymnastics. AgentFactory receives a strongly-typed GitHubCliBuiltinToolsTool and passes it (or a projected CopilotBuiltinToolPolicy derived from it) directly to CopilotSdkChatClient.
- The typed subclass co-composes with the schema-level enforcement described above: the JSON schema constrains the wire format, System.Text.Json deserialisation constrains the CLR shape, and both agree on the same property names / types.
- Discoverability —
git grep GitHubCliBuiltinToolsTool finds every producer and consumer, unlike an untyped string key "github-cli-builtin-tools" in an Options bag.
The Options bag on the base CustomTool remains for genuinely open-ended kinds; new provider-specific kinds should follow the GitHubCliBuiltinToolsTool pattern (typed subclass + schema subschema).
Affected Files
| File |
Change |
Directory.Packages.props |
No version bump required. Pins GitHub.Copilot.SDK 1.0.8, which already exposes AvailableTools/ExcludedTools, BuiltInTools.Isolated, and ToolSet with source-qualified wildcards. |
Phantom.Workspaces.Llm.Core/JsonSchemas/AgentDefinition.json |
Add a $defs/githubCliBuiltinToolsTool subschema with kind: { const: "github-cli-builtin-tools" }, typed available-tools / excluded-tools selectors ({ oneOf: [ {tools: string[]}, {isolated: bool} ] }), and a typed client-mode: { enum: ["empty","copilot-cli"] }. Insert it into tools[] anyOf before $defs/tool. Amend the generic $defs/tool to exclude kind == "github-cli-builtin-tools" (see "Schema-level enforcement") so a malformed entry cannot silently fall through to the permissive generic. Update the kind field's description to list the recognised kinds. |
AgentSchema package |
Model change required. Introduce GitHubCliBuiltinToolsTool : CustomTool with typed properties AvailableTools : BuiltinToolSet?, ExcludedTools : BuiltinToolSet?, ClientMode : CopilotClientMode = CopilotCli. Introduce the typed record BuiltinToolSet { IReadOnlyList<string>? Tools; bool Isolated }. Wire a kind-discriminated System.Text.Json polymorphic converter (or extend the existing CustomTool converter) so "kind": "github-cli-builtin-tools" deserialises into GitHubCliBuiltinToolsTool and unknown kinds continue to deserialise to the base CustomTool (backwards-compatible). |
Phantom.Workspaces.Llm.Core/AgentFactory.cs |
In CreateGitHubCopilotClient (~L798–841) and CreateGitHubCopilotByokClient (~L847–895) — and the sibling sites at ~L933 and ~L989 — find the single GitHubCliBuiltinToolsTool entry via promptAgent.Tools.OfType<GitHubCliBuiltinToolsTool>().SingleOrDefault(), project it to a CopilotBuiltinToolPolicy (source-qualification and auto-append rules applied here), and pass it to the CopilotSdkChatClient constructor. Skip / filter this subtype out of ExtractTools (~L148–151) so it isn't handed to ToolsetFactory. |
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs |
Accept a CopilotBuiltinToolPolicy? (default null = SDK defaults) via constructor; apply it in BuildSessionConfig (~L260–306) and BuildResumeSessionConfig (~L314–360) as a native passthrough onto SessionConfig.AvailableTools and SessionConfig.ExcludedTools. Custom tools (SessionConfig.Tools) and MCP tools continue to flow through unchanged. |
Phantom.Workspaces.Llm.Core.Tests/AgentFactoryTests.cs |
New tests (see below). |
Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientTests.cs |
New tests asserting SessionConfig.AvailableTools / SessionConfig.ExcludedTools shape for each form and slot. |
Phantom.Workspaces.Llm.Core.Tests/AgentDefinitionJsonSchema*Tests.cs |
Schema round-trip tests for the new $defs/githubCliBuiltinToolsTool subschema, plus negative tests confirming that malformed github-cli-builtin-tools entries do NOT fall through to the generic $defs/tool (the not amendment on $defs/tool is what closes that hole). |
docs/design/github-copilot-provider-support.md |
Document the two slots, the shared selector, the three forms, the precedence rule, and the source-prefix rewrite. |
Design / Fix (Canonical) — shared selector + two slots + native passthrough
Represent the built-in-tool policy as a CustomTool entry with kind: "github-cli-builtin-tools" whose options bag carries two independent selector slots — available-tools and excluded-tools — each a BuiltinToolSet selector. Map both onto the pinned SDK's SessionConfig.AvailableTools and SessionConfig.ExcludedTools, with the SDK's own wildcard "builtin:*" used natively when the selector says "*".
BuiltinToolSet selector — resolution table
A selector object is resolved to a ResolvedToolSet — either Absent ("selector not present"), or Concrete(IReadOnlyList<string>) (a possibly-empty list of bare built-in tool names; "*" is a permitted member meaning "all built-ins"):
| Selector shape |
Resolved meaning |
{ "tools": ["*"] } |
Concrete(["*"]) — the wildcard is a normal list member |
{ "tools": ["a","b"] } |
Concrete(["a","b"]) |
{ "tools": ["*","x"] } |
Concrete(["*","x"]) — permitted; the SDK sees builtin:* and builtin:x |
{ "isolated": true } |
Concrete(BuiltInTools.Isolated.ToArray()) |
{ "tools": [] } |
Concrete([]) — explicit empty |
{ } / neither key set |
Absent (with warning) |
| (selector absent from slot) |
Absent |
Presence check on Tools uses null-vs-empty (selector.Tools is { } tools succeeds for [] and fails for absent) so [] is meaningfully distinct from an absent tools key. Precedence when more than one key is set: tools > isolated.
Selector-to-SDK mapping — per slot
Applied identically to SessionConfig and ResumeSessionConfig. Bare names (no :) are rewritten to their source-qualified form by prepending builtin: (equivalent to calling new ToolSet().AddBuiltIn(name) per element). Source-qualified entries (containing : — e.g. mcp:*, custom:*, builtin:*, mcp:github-list_issues) flow through verbatim and suppress the custom:*/mcp:* auto-append (see rule above).
available-tools selector → SessionConfig.AvailableTools:
| Selector |
AvailableTools |
{ "tools": ["*"] } |
Leave unset (null) — see note below on why we do NOT populate ["builtin:*"] on the allow-list. |
{ "tools": ["a","b"] } |
["builtin:a", "builtin:b", "custom:*", "mcp:*"] — bare-only ⇒ custom:* and mcp:* auto-appended. |
{ "tools": ["a","*"] } |
["builtin:a", "builtin:*", "custom:*", "mcp:*"] — bare-only ⇒ auto-append still applies. |
{ "tools": ["mcp:*"] } |
["mcp:*"] — MCP-only (built-ins AND custom off). Source-qualified ⇒ no auto-append. See the "MCP-only agent" recipe. |
{ "tools": ["mcp:*","custom:*"] } |
["mcp:*","custom:*"] — MCP + custom only (built-ins off). Source-qualified ⇒ no auto-append. |
{ "tools": ["mcp:github-list_issues"] } |
["mcp:github-list_issues"] — a single MCP tool by its runtime canonical wire name. mcp:* matches ALL MCP servers; there is no server-scoped wildcard, so to restrict to one server list each tool individually. |
{ "tools": ["a","mcp:*"] } |
["builtin:a","mcp:*"] — mixed bare + source-qualified: bare "a" is prefixed; presence of the source-qualified mcp:* suppresses auto-append. |
{ "isolated": true } |
new ToolSet().AddBuiltIn(BuiltInTools.Isolated).AddCustom("*").AddMcp("*") → ["builtin:ask_user", …, "custom:*", "mcp:*"] — isolated is a bare-form shorthand, so auto-append applies. |
{ "tools": [] } |
["custom:*", "mcp:*"] — no built-ins, but custom and MCP tools remain reachable (empty list is treated as the bare-only case). |
| (slot absent) |
Leave unset |
CRITICAL — allow-list hides all other sources. SessionConfig.AvailableTools is a global allow-list across builtin:, custom:, and mcp: (SDK Types.cs:265-269, 3011-3015, 331-337; session.create always sets toolFilterPrecedence: "excluded" so ExcludedTools wins over AvailableTools). If a caller writes available-tools: { "tools": ["bash","edit"] } intending "restrict built-ins to bash/edit", a naïve mapping to AvailableTools = ["builtin:bash","builtin:edit"] would ALSO silently deny every custom tool and every MCP tool — including the agent's own MCP-server tools declared elsewhere in the same AgentDefinition. This is almost never the author's intent — this tool kit is called github-cli-builtin-tools and its purpose is to govern built-ins only.
Chosen policy — bare-name-triggered auto-append. The rule is:
- Every element in
available-tools.tools is a bare built-in name (matches ^[a-zA-Z0-9_-]+$ or is "*" — NO colon) ⇒ auto-append custom:* and mcp:* to the resulting AvailableTools. The caller clearly meant "govern built-ins only, leave the rest untouched".
- ANY element is a source-qualified pattern (contains
: — e.g. mcp:*, custom:*, builtin:*, mcp:github-list_issues) ⇒ honor exactly what the caller wrote; do NOT auto-append anything. Presence of an explicit source-qualified pattern is treated as the caller taking full responsibility for the cross-source allow-list.
This is what makes { "available-tools": { "tools": ["mcp:*"] } } mean strictly "MCP-only" (built-ins AND custom off) — see the worked "MCP-only agent" recipe below. The excluded-tools slot is unaffected by this rule (it is a deny-list — appending custom:*/mcp:* to an exclusion would break the agent).
Rejected alternatives: (b) strict pass-through as a global allow-list — technically faithful to the SDK, but almost certainly the wrong behaviour for this kit and produces silent, hard-to-debug tool-disappearance bugs; (c) refuse specific available-tools lists entirely and steer users to excluded-tools — too restrictive.
Ergonomic guidance (documented in the schema description and docs): for the common "disable a few built-ins" case, prefer excluded-tools (a deny-list). Because session.create sets toolFilterPrecedence: "excluded", ExcludedTools wins over AvailableTools, and a deny-list never accidentally hides other sources.
Note (unset-vs-explicit-["*"] decision on available-tools). For the sole-wildcard case { "tools": ["*"] }, leave AvailableTools unset (null) — SDK default = all sources permitted. This is why removing the dedicated all boolean is safe: { "tools": ["*"] } on available-tools and the previous { "all": true } both mean "SDK default", and both map to "leave unset". On excluded-tools, the wildcard flows through natively — see next table.
excluded-tools selector → SessionConfig.ExcludedTools:
| Selector |
ExcludedTools |
{ "tools": ["*"] } |
["builtin:*"] — native SDK wildcard meaning "exclude all built-ins". No alias hack needed. |
{ "tools": ["a","b"] } |
["builtin:a", "builtin:b"] |
{ "isolated": true } |
new ToolSet().AddBuiltIn(BuiltInTools.Isolated) → ["builtin:ask_user", …] |
{ "tools": [] } |
new List<string>() (exclude nothing — no-op) |
| (slot absent) |
Leave unset |
Combined slots: both slots may coexist. Final effective built-in tool set = AvailableTools \ ExcludedTools. Absent slots do not touch the corresponding SDK field.
Absent github-cli-builtin-tools entry: neither field touched ⇒ SDK default (all built-ins). Semantically equivalent to { "available-tools": { "tools": ["*"] } } with no excluded-tools slot.
Precedence within a selector (multiple keys set)
Highest wins:
tools (any list, including []) — most specific.
isolated: true.
- None set (empty selector object) ⇒ rejected by validation (see "Validation" section below).
JSON examples
Worked recipe — "MCP-only agent" (disable all built-ins, keep only MCP server tools)
Users frequently want an agent that has no Copilot built-ins available and routes exclusively to the MCP servers the agent declares. There are two idiomatic expressions of this, and the semantic difference matters — both must be documented as first-class in the schema examples.
1. Allowlist form — strictly MCP-only (also drops custom/SDK tools)
- Because
SessionConfig.AvailableTools is a global allow-list across every source, setting it to ["mcp:*"] means only tools whose source-qualified name matches mcp:* are available. All builtin:* are off; all custom:* are off; only MCP tools remain.
- Under the "bare-name-triggered auto-append" rule above, the presence of the source-qualified pattern
mcp:* suppresses the custom:* auto-append — this is what makes the recipe actually "MCP-only" instead of "MCP + custom".
- Use this form when the requirement is "the model can call only our MCP tools; nothing else exists".
2. Denylist form — turn off built-ins, keep MCP + any custom/SDK tools
ExcludedTools = ["builtin:*"] starts from the SDK default (everything available across all sources) and removes every builtin:* entry. MCP tools and custom tools survive. Because session.create always sets toolFilterPrecedence: "excluded", this is guaranteed to win over AvailableTools.
- Use this form when the requirement is "keep everything we register (MCP + custom + user tools), just drop the SDK built-ins".
Guidance
- "Only our MCP tools, nothing else" → allowlist form (
available-tools: { "tools": ["mcp:*"] }).
- "Keep everything we register except the built-ins" → denylist form (
excluded-tools: { "tools": ["builtin:*"] }).
mcp:* matches all MCP servers configured for the agent. There is no server-scoped wildcard in the SDK. To restrict to one specific MCP server, enumerate each tool by its runtime canonical wire name (mcp:<wire-name>, e.g. mcp:github-list_issues) — this is derived from SDK sources ToolSet.cs (AddBuiltIn→"builtin:{name}", AddCustom→"custom:{name}", AddMcp→"mcp:{toolName}"; ValidateName allows * or ^[a-zA-Z0-9_-]+$) and Types.cs:265-269,331-337.
- The canonical "locked-down" configuration pairs the allowlist form with
client-mode: "empty" (next section):
client-mode — opt into CopilotClientMode.Empty (client-construction property)
A new option on the github-cli-builtin-tools CustomTool options bag opts an agent into the SDK's alternative client mode:
Default: "copilot-cli" (identical to today's behaviour).
SDK facts (verified in C:\dev\microsoft\copilot-sdk\dotnet\src\Types.cs:249-345):
enum CopilotClientMode { Empty, CopilotCli }. Default CopilotCli.
- CRITICAL:
Mode lives on CopilotClientOptions (CLIENT-level, per CopilotClient), NOT on SessionConfig / ResumeSessionConfig. Selecting Empty is therefore a property of how the CopilotClient backing this agent is constructed, not a per-session tweak. AgentFactory (and the client-construction / client-caching path — the four new CopilotSdkChatClient(...) sites at ~L829, ~L883, ~L933, ~L989) must build (or select from a pool) a CopilotClient whose CopilotClientOptions.Mode = Empty for such definitions. If the current architecture reuses one CopilotClient across multiple agents, that reuse must be keyed on Mode (and on BaseDirectory/SessionFs) so an Empty-mode agent never accidentally shares a CopilotCli-mode client.
- In
Empty mode the SDK requires:
CopilotClientOptions.BaseDirectory or CopilotClientOptions.SessionFs set on the client ctor;
AvailableTools supplied on every SessionConfig (Empty exposes no tools by default — see the DLL diagnostic string quoted above under §4);
session.create always sets toolFilterPrecedence: "excluded" (so ExcludedTools still wins over AvailableTools);
- Safe ambient defaults are injected and
COPILOT_DISABLE_KEYTAR=1 is set.
Placement rationale. Placing client-mode on the github-cli-builtin-tools CustomTool options (rather than as a top-level AgentDefinition field or a separate github-copilot-client tool kind) is consistent with the rest of this design: this tool kit is already the single Copilot-SDK-provider-specific configuration surface on the agent, and client-mode and available-tools are inextricably linked (Empty mode requires available-tools). Keeping them in the same JSON object keeps the interlock local, validatable in one pass, and discoverable by anyone reading the tool entry. A top-level AgentDefinition field would split the interlock across two schema locations and cross the "tool policy lives in tools[]" invariant (see Alternative D in Considered / Background).
Fail-fast validation for client-mode (in addition to the general validation table below):
| Rule |
Detail |
On violation |
client-mode is a string of "empty" or "copilot-cli" |
Case-insensitive, but the canonical serialised form is lowercase-kebab. false/null/other strings rejected. |
Reject with 'client-mode' must be one of "empty" or "copilot-cli"; got <actual>. |
Empty mode ⇒ available-tools is present and non-empty |
Mirrors the SDK's own requirement. available-tools: { "tools": [] } counts as empty for this rule (Empty mode with zero tools would be an unusable agent). |
Reject with 'client-mode: empty' requires 'available-tools' to be present and non-empty; Empty mode exposes no tools by default so every session must opt in. |
Empty mode ⇒ BaseDirectory or SessionFs is configured |
AgentFactory's client-construction path is responsible for ensuring one of these is set on the CopilotClientOptions used to build the CopilotClient. If neither can be resolved from the agent's provider/account context, reject at agent-load time. |
Reject with 'client-mode: empty' requires the underlying CopilotClient to be constructed with BaseDirectory or SessionFs; neither could be resolved for this AgentDefinition. |
Natural pairing. Empty mode + available-tools: { "tools": ["mcp:*"] } (see recipe above) is the canonical "locked-down, MCP-only" configuration: the SDK opens the client in Empty mode (nothing exposed by default), and each session opts into exactly mcp:*. Both requirements — non-empty available-tools and MCP-only surface — are satisfied simultaneously.
AgentSchema types — JSON-deserialisable
// AgentSchema — deserialisable via System.Text.Json. Wire names use kebab-case
// via [JsonPropertyName]; the schema's typed shape (see "Schema-level enforcement")
// is the authoritative gate on which combinations are legal.
public sealed record BuiltinToolSet(
[property: JsonPropertyName("tools")] IReadOnlyList<string>? Tools,
[property: JsonPropertyName("isolated")] bool Isolated);
// Typed subclass of AgentSchema.CustomTool. Deserialised by a kind-discriminated
// polymorphic converter — an entry with "kind": "github-cli-builtin-tools" lands here;
// unknown kinds still deserialise to the base CustomTool for backwards compatibility.
public sealed class GitHubCliBuiltinToolsTool : CustomTool
{
public const string KindName = "github-cli-builtin-tools";
[JsonPropertyName("available-tools")] public BuiltinToolSet? AvailableTools { get; init; }
[JsonPropertyName("excluded-tools")] public BuiltinToolSet? ExcludedTools { get; init; }
[JsonPropertyName("client-mode")] public CopilotClientMode ClientMode { get; init; } = CopilotClientMode.CopilotCli;
}
CopilotBuiltinToolPolicy model — projected view for CopilotSdkChatClient
/// <summary>
/// Projected view of a GitHubCliBuiltinToolsTool after applying source-qualification
/// and the auto-append rule. Each list is applied verbatim to its corresponding SDK
/// field. A null list means "leave that SDK field unset". List elements are already
/// source-qualified (e.g. "builtin:shell", "builtin:*", "mcp:*", "mcp:github-list_issues").
/// ClientMode is applied at CopilotClient construction time (CopilotClientOptions.Mode),
/// NOT at session-config build time.
/// </summary>
public sealed record CopilotBuiltinToolPolicy(
IReadOnlyList<string>? AvailableTools,
IReadOnlyList<string>? ExcludedTools,
GitHub.Copilot.CopilotClientMode ClientMode); // default CopilotCli
AgentFactory wiring — typed projection to CopilotBuiltinToolPolicy
Because GitHubCliBuiltinToolsTool is a strongly-typed subclass of CustomTool, AgentFactory retrieves it directly with no dictionary unboxing:
// A selector resolves to one of two outcomes.
private abstract record ResolvedToolSet
{
public sealed record Absent : ResolvedToolSet;
/// <summary>Bare tool names as authored (may include the wildcard "*").</summary>
public sealed record Concrete(IReadOnlyList<string> Tools) : ResolvedToolSet;
}
// Resolve a typed BuiltinToolSet into a ResolvedToolSet. Precedence: tools > isolated.
// Empty-selector / unknown-key / wrong-type cases are already rejected at schema-validate
// time (see "Schema-level enforcement"), so this method only handles well-formed input.
static ResolvedToolSet ResolveBuiltinToolSet(BuiltinToolSet? selector, string slotName, ILogger? log)
{
if (selector is null) return new ResolvedToolSet.Absent();
if (selector.Tools is { } tools && selector.Isolated)
log?.LogWarning(
"github-cli-builtin-tools {Slot}: both 'tools' and 'isolated' set; " +
"applying precedence tools > isolated.", slotName);
if (selector.Tools is { } t) return new ResolvedToolSet.Concrete(t);
if (selector.Isolated) return new ResolvedToolSet.Concrete(GitHub.Copilot.BuiltInTools.Isolated.ToArray());
return new ResolvedToolSet.Absent(); // defensive; schema rejects {}
}
// Source-qualify: bare names and the wildcard become "builtin:<name>" / "builtin:*".
// Source-qualified names (containing ':') are passed through verbatim.
static IReadOnlyList<string> ToSdkBuiltinList(IReadOnlyList<string> names)
{
var set = new GitHub.Copilot.ToolSet();
foreach (var n in names)
{
if (n.Contains(':')) set.Add(n); // already source-qualified — verbatim
else set.AddBuiltIn(n);
}
return set.ToList();
}
// Build the AvailableTools list from a resolved built-in selector, applying the
// bare-name-triggered auto-append rule. When ANY element is source-qualified
// (contains ':'), the caller has taken explicit responsibility for the cross-source
// allow-list, so no auto-append happens.
static IReadOnlyList<string> ToSdkAvailableList(IReadOnlyList<string> names)
{
var anySourceQualified = names.Any(n => n.Contains(':'));
var set = new GitHub.Copilot.ToolSet();
foreach (var n in names)
{
if (n.Contains(':')) set.Add(n);
else set.AddBuiltIn(n);
}
if (!anySourceQualified)
{
set.AddCustom("*");
set.AddMcp("*");
}
return set.ToList();
}
static CopilotBuiltinToolPolicy? ExtractBuiltinToolPolicy(AgentDefinition agent, ILogger? log)
{
if (agent is not PromptAgent promptAgent || promptAgent.Tools is null) return null;
// Direct, typed lookup — no dictionary spelunking.
var tool = promptAgent.Tools.OfType<GitHubCliBuiltinToolsTool>().SingleOrDefault();
if (tool is null) return null;
// Cross-field validation (e.g. client-mode: empty ⇒ available-tools required) is
// performed here — the schema constrains individual field shapes but does not
// express inter-field interlocks. See "Cross-field runtime checks" below.
ValidateClientModeInterlock(tool);
var available = ResolveBuiltinToolSet(tool.AvailableTools, "available-tools", log);
var excluded = ResolveBuiltinToolSet(tool.ExcludedTools, "excluded-tools", log);
IReadOnlyList<string>? availableList = null;
if (available is ResolvedToolSet.Concrete cAvail)
{
// Sole-wildcard optimisation: {"tools":["*"]} means "SDK default, all sources".
if (cAvail.Tools.Count == 1 && cAvail.Tools[0] == "*")
availableList = null;
else
availableList = ToSdkAvailableList(cAvail.Tools);
}
IReadOnlyList<string>? excludedList = excluded is ResolvedToolSet.Concrete cExcl
? ToSdkBuiltinList(cExcl.Tools) // may be empty; "*" → "builtin:*"
: null;
return new CopilotBuiltinToolPolicy(availableList, excludedList, tool.ClientMode);
}
// At each CopilotSdkChatClient construction site (~L829 / ~L883 / ~L933 / ~L989):
var builtinPolicy = ExtractBuiltinToolPolicy(agent, services?.LoggerFactory?.CreateLogger(...));
var client = new CopilotSdkChatClient(
modelId, displayName, gitHubToken, services?.LoggerFactory,
queueManager: queueManager,
modelOptions: model.Options,
subAgentChatRegistry: subAgentChatRegistry,
accountUpsertService: services?.AccountUpsertService,
slashCommandRegistry: services?.SlashCommandRegistry as SlashCommands.ISlashCommandRegistry,
builtinToolPolicy: builtinPolicy); // null ⇒ SDK defaults
ExtractTools (~L148–151) is updated to skip entries of concrete type GitHubCliBuiltinToolsTool so ToolsetFactory never sees them.
Schema-level enforcement (authoritative gate)
The AgentDefinition.json schema is the authoritative shape gate for github-cli-builtin-tools entries. This is the owner-preferred approach: an incorrectly-generated definition must fail JSON-schema validation at agent load, not silently mis-apply, and other tool kinds must keep their own permissive schemata.
How it composes with tools[] anyOf — no fragility. The trick that avoids the previously-feared anyOf / if/then ordering problem is a two-part change that leaves other tool kinds' schemata untouched:
-
Add $defs/githubCliBuiltinToolsTool — a fully-typed subschema:
-
Insert githubCliBuiltinToolsTool first in tools[]'s anyOf, and amend the generic $defs/tool to exclude this specific kind. The generic tool's own definition is unchanged for every other kind; it merely gains a not constraint saying "I am not a github-cli-builtin-tools":
This closes the "silent fall-through to the permissive generic" hole: a malformed github-cli-builtin-tools entry cannot satisfy $defs/tool (blocked by the not) and cannot satisfy $defs/githubCliBuiltinToolsTool (blocked by additionalProperties: false and the typed oneOf on the selector), so anyOf fails and validation loudly rejects the definition. Other kinds keep the permissive additionalProperties: true behaviour of the generic tool exactly as today.
What the schema enforces (no runtime duplication):
- Only
available-tools, excluded-tools, and client-mode are permitted top-level options (additionalProperties: false).
- Each selector is a
builtinToolSet — exactly one of tools or isolated (oneOf), no other keys (additionalProperties: false), isolated must be the literal true (false is meaningless).
tools is a string array; each element matches the wire-name pattern ("*" or ^[a-zA-Z0-9_-]+$ or a source-qualified ^(builtin|custom|mcp):(\*|[a-zA-Z0-9_-]+)$).
client-mode is one of "empty" / "copilot-cli".
- Cross-field interlock:
client-mode: "empty" requires available-tools present and non-empty. Expressed via the if/then inside the subschema's allOf.
Cross-field runtime checks (belt-and-suspenders). A small ValidateClientModeInterlock(GitHubCliBuiltinToolsTool) in AgentFactory re-checks the client-mode: empty ⇒ available-tools non-empty interlock (redundant with the schema, kept for defence-in-depth against callers that construct AgentDefinition in-memory and bypass schema validation) and additionally enforces the environmental precondition that the SDK does not express: Empty mode requires BaseDirectory or SessionFs to be resolvable at CopilotClient construction time. These are the only rules that live in code — every wire-shape rule lives in the schema.
Known-name allow-list — deliberately NOT enforced (schema or runtime). Neither layer hard-codes a known set of built-in tool names beyond the wire-name pattern above. Rationale: the SDK classifies built-in tool names at runtime (in session.create) and the set of built-ins can change between SDK versions (GitHub.Copilot.SDK may add new tools in future 1.0.x releases). Hard-coding a name allow-list would cause valid definitions to be rejected after an SDK bump. If a user misspells a tool name, the SDK itself will refuse to route calls to it — a benign failure mode that surfaces on first call.
Validation error mapping. Because the schema does the shape enforcement, callers get standard JSON-schema errors with JSON-pointer paths (e.g. /tools/2/available-tools/tools/0). AgentDefinitionValidationException should surface those verbatim (as elsewhere in the loader) so LLM-authored definitions receive the same actionable feedback humans do.
CopilotSdkChatClient enforcement — native passthrough
Both BuildSessionConfig (~L260–306) and BuildResumeSessionConfig (~L314–360) apply the same block after constructing sessionConfig. ResumeSessionConfig shares the identical property surface, so the code is symmetric:
if (_builtinToolPolicy is { } policy)
{
if (policy.AvailableTools is { } available)
sessionConfig.AvailableTools = available.ToList(); // [] or populated (source-qualified)
if (policy.ExcludedTools is { } excluded)
sessionConfig.ExcludedTools = excluded.ToList();
}
// Absent policy or absent slot ⇒ leave SDK field unset ⇒ SDK default applies.
// OnPermissionRequest remains PermissionHandler.ApproveAll — the SDK's own
// AvailableTools/ExcludedTools filter runs before any built-in reaches the model.
// SessionConfig.Tools (user custom tools) and MCP tools are unaffected.
Considered / Background (NOT chosen)
Superseded — earlier four-form selector with a dedicated all boolean
Before the SDK's source-qualified wildcard "builtin:*" was verified, the canonical selector had four forms with a dedicated boolean:
| # |
Form |
Meaning |
| 1 |
{ "all": true } |
The entire built-in tool set |
| 2 |
{ "tools": [...] } |
Exactly the named tools |
| 3 |
{ "isolated": true } |
The isolated built-in tool set |
| 4 |
{ "tools": [] } |
The empty set |
Precedence was tools > isolated > all. The all boolean was collapsed into the tools list once the wildcard "*" was confirmed: form #1 { "all": true } is now expressed as { "tools": ["*"] }, and form #1 on available-tools continues to map to "leave unset" (the wildcard's canonical mapping — see design note).
Superseded — excluded-tools: { "all": true } alias hack
Under the four-form design, excluded-tools: { "all": true } had no clean SDK translation because BuiltInTools exposes no enumeration of all built-in tool names. The workaround was to treat it as a documented alias for available-tools: { "tools": [] }, setting SessionConfig.AvailableTools = new List<string>() on the available slot and leaving ExcludedTools untouched — with a warning about the aliasing and an edge-case rule for what to do if both slots were present.
This hack is now retired. With the source-qualified wildcard confirmed, { "excluded-tools": { "tools": ["*"] } } maps natively to ExcludedTools = ["builtin:*"] — no cross-slot rewrite, no warning, no lost expressive power (MCP and custom tools remain reachable when only built-ins are excluded, which the old alias did not preserve). This is a strict semantic improvement over the alias.
Alternative A — earlier include-only shape (include-all / include / include-isolated)
An even earlier canonical design expressed the policy as an include-only allow-list on the CustomTool options bag:
options.include-all: bool ⇒ AvailableTools = null (SDK default, all built-ins).
options.include: string[] ⇒ AvailableTools = [...] (empty list = no built-ins).
options.include-isolated: bool ⇒ AvailableTools = BuiltInTools.Isolated.ToList().
Rejected in favour of the reusable-selector-plus-two-slots shape because it could not express "exclude a specific tool from the SDK default set" without listing every remaining built-in by name (fragile as the SDK's default set evolves), used three parallel keys whose semantics were not reusable in an excluded-tools slot, and did not compose across allow + deny.
Alternative B — earlier disableAll / disabled (exclude-based) schema
An even earlier design expressed the policy as two exclusion-shaped properties in the options bag: options.disableAll: bool and options.disabled: string[]. Rejected because it only expressed the deny-list side; there was no natural way to express the isolated set, no way to allow-list a small set of tools, and no way to compose allow + deny.
Alternative C — wrapper-layer OnPermissionRequest clamp + SessionConfig.Tools withholding
An early proposal (from when the SDK surface was believed to lack native disable knobs) enforced the policy at the wrapper layer by composing OnPermissionRequest to deny disabled built-in tool names. Rejected because pinned SDK 1.0.8 exposes AvailableTools / ExcludedTools (with source-qualified wildcards) directly (see §4), which the SDK applies before any tool reaches the model. An OnPermissionRequest deny still lets the model attempt the call and only refuses at invocation time. Preserved as the documented fallback should a future SDK regress or rename these fields.
Alternative D — top-level AgentDefinition fields
An earlier proposal expressed the policy as top-level AgentDefinition fields (disableDefaultTools: bool, disabledDefaultTools: string[]). Rejected because tool policy is naturally scoped to tools[]; expressing it as a tool entry keeps AgentDefinition's top-level surface small and consistent with how MCP allow-lists and agent-definition tools are modelled. A kind-discriminated tool entry also composes cleanly with future provider-specific tool policies (e.g. an analogous openai-builtin-tools kind).
Alternative E — dedicated $defs/githubCliBuiltinToolsTool discriminated subschema (now the chosen approach)
An intermediate proposal added a new $defs/githubCliBuiltinToolsTool subschema (with kind: { const: "github-cli-builtin-tools" } and typed properties) and inserted it into the tools[] anyOf before the generic tool. It was briefly rejected out of concern that JSON Schema anyOf / if/then composition would be fragile in downstream validators and would silently fall through to the permissive generic $defs/tool.
Reinstated (owner directive — see "Schema-level enforcement" above). The fall-through concern is closed by amending the generic $defs/tool with a not constraint that excludes this specific kind, so anyOf cannot silently accept a malformed github-cli-builtin-tools entry via the generic branch. Every other kind's schema is unchanged. The typed subschema plus the not amendment together make the JSON schema the authoritative shape gate; runtime validation is reduced to cross-field interlocks that the schema cannot express (see "Cross-field runtime checks").
The considered-but-not-chosen designs are preserved so the alternatives aren't lost.
Expected Tests
Follow the Subject_Scenario_ExpectedOutcome naming already used in Phantom.Workspaces.Llm.Core.Tests (see AgentFactoryTests.ConfigureChatOptions_MapsThinkingToReasoningEffort and AgentFactoryTests.CreateChatClient_GitHubCopilotProvider_ReturnsCopilotSdkClient).
| Test Name |
Class |
What It Verifies |
AgentFactory_AvailableToolsWildcard_LeavesAvailableToolsUnset |
AgentFactoryTests |
{ "available-tools": { "tools": ["*"] } } produces a policy whose AvailableTools is null, so SessionConfig.AvailableTools is not touched (SDK default = all sources). Regression guard for the "do not populate ["builtin:*"] alone" rule. |
AgentFactory_AvailableToolsList_SetsAvailableTools |
AgentFactoryTests |
{ "available-tools": { "tools": ["read_agent","list_agents"] } } sets SessionConfig.AvailableTools == ["builtin:read_agent","builtin:list_agents","custom:*","mcp:*"] in exact order — the trailing custom:* and mcp:* are auto-appended to preserve non-built-in tools. |
AgentFactory_AvailableToolsSpecificList_PreservesCustomAndMcp |
AgentFactoryTests |
Regression guard for the allow-list-hides-all-sources caveat: with available-tools: { "tools": ["bash","edit"] } and an agent that also declares an MCP tool and a custom tool elsewhere in its AgentDefinition.tools[], both custom:* and mcp:* are present in SessionConfig.AvailableTools alongside builtin:bash / builtin:edit, so the agent's own custom/MCP tools remain reachable. |
AgentFactory_AvailableToolsWildcardMixed_PassesThrough |
AgentFactoryTests |
{ "available-tools": { "tools": ["*","read_agent"] } } sets SessionConfig.AvailableTools == ["builtin:*","builtin:read_agent","custom:*","mcp:*"] — mixed lists containing "*" are passed through verbatim (author-explicit intent) with custom:* / mcp:* still appended. |
AgentFactory_AvailableToolsIsolated_SetsAvailableToolsToIsolatedSet |
AgentFactoryTests |
{ "available-tools": { "isolated": true } } sets SessionConfig.AvailableTools equal to new ToolSet().AddBuiltIn(BuiltInTools.Isolated).AddCustom("*").AddMcp("*") (i.e. ["builtin:ask_user", …, "custom:*", "mcp:*"]), element-wise. |
AgentFactory_AvailableToolsEmpty_SetsAvailableToolsToCustomAndMcpOnly |
AgentFactoryTests |
{ "available-tools": { "tools": [] } } sets SessionConfig.AvailableTools == ["custom:*","mcp:*"] — no built-ins, but custom and MCP tools remain reachable. |
AgentFactory_ExcludedToolsWildcard_SetsExcludedToolsToBuiltinStar |
AgentFactoryTests |
{ "excluded-tools": { "tools": ["*"] } } sets SessionConfig.ExcludedTools == ["builtin:*"] — native SDK wildcard, no alias, AvailableTools untouched. |
AgentFactory_ExcludedToolsList_SetsExcludedTools |
AgentFactoryTests |
{ "excluded-tools": { "tools": ["shell"] } } sets SessionConfig.ExcludedTools == ["builtin:shell"]. |
AgentFactory_ExcludedToolsIsolated_SetsExcludedToolsToIsolatedSet |
AgentFactoryTests |
{ "excluded-tools": { "isolated": true } } sets SessionConfig.ExcludedTools equal to new ToolSet().AddBuiltIn(BuiltInTools.Isolated). |
AgentFactory_ExcludedToolsEmpty_LeavesExcludedToolsEmptyList |
AgentFactoryTests |
{ "excluded-tools": { "tools": [] } } sets SessionConfig.ExcludedTools to a non-null, empty list (no-op deny). |
AgentFactory_AvailableAndExcludedCombined_AppliesBoth |
AgentFactoryTests |
{ "available-tools": { "isolated": true }, "excluded-tools": { "tools": ["exit_plan_mode"] } } sets AvailableTools = ["builtin:ask_user", …, "custom:*", "mcp:*"] and ExcludedTools = ["builtin:exit_plan_mode"] on SessionConfig. |
AgentFactory_NoBuiltinToolsEntry_LeavesDefaults |
AgentFactoryTests |
Omitting the github-cli-builtin-tools entry yields a null policy so SessionConfig.AvailableTools and SessionConfig.ExcludedTools remain unset (regression guard). |
AgentFactory_SelectorBothToolsAndIsolated_AppliesPrecedence |
AgentFactoryTests |
When both tools and isolated are set on the same selector, precedence tools > isolated is applied and a warning is logged (this remains a warn, not a reject). |
AgentFactory_BuiltinToolsUnknownOptionKey_SchemaRejects |
AgentDefinitionJsonSchemaTests |
Loading an AgentDefinition whose github-cli-builtin-tools entry contains an unrecognised key (e.g. { "kind": "github-cli-builtin-tools", "disabledTools": [...] }) fails JSON-schema validation with a message naming the offending path. Enforced by additionalProperties: false on $defs/githubCliBuiltinToolsTool plus the not amendment on $defs/tool that prevents fall-through. Fail-fast LLM-safety guard. |
AgentFactory_BuiltinToolsSelectorNeitherToolsNorIsolated_Throws |
AgentFactoryTests |
An empty selector object (e.g. available-tools: {}) throws with a message stating that the selector must set either tools or isolated. Replaces the earlier "empty selector is ignored with warning" behaviour — reject is safer for LLM-authored definitions. |
AgentFactory_BuiltinToolsSelectorUnknownKey_Throws |
AgentFactoryTests |
A selector containing an unrecognised key (e.g. available-tools: { "include": ["x"] }) throws naming the offending key. |
AgentFactory_BuiltinToolsSelectorWithBadToolName_Throws |
AgentFactoryTests |
A tools list containing a name with disallowed characters (e.g. "builtin:shell" — already-prefixed — or "foo bar") throws with a message quoting the offending name and the required regex. Enforces ToolSet.ValidateName semantics up-front. |
AgentFactory_BuiltinToolsSelectorToolsNotArray_Throws |
AgentFactoryTests |
If tools is present but not a JSON array (e.g. a string), throws with a message stating tools must be an array of strings. |
AgentFactory_BuiltinToolsSelectorToolsElementNotString_Throws |
AgentFactoryTests |
If tools contains a non-string element (e.g. ["shell", 42]), throws naming the offending index and its actual type. |
AgentFactory_BuiltinToolsIsolatedNonBool_Throws |
AgentFactoryTests |
If isolated is present but not a boolean (e.g. "true", 1), throws with a message stating isolated must be a boolean. |
AgentFactory_BuiltinToolsSlotNotObject_Throws |
AgentFactoryTests |
If a slot's value is not a JSON object (e.g. available-tools: ["shell"]), throws with a message stating the slot must be a selector object. |
AgentFactory_BuiltinToolsEntry_IsNotForwardedToToolsetFactory |
AgentFactoryTests |
The github-cli-builtin-tools entry is filtered out of ExtractTools (~L148–151) and never reaches ToolsetFactory. |
AgentFactory_BuiltinToolsEntry_OnNonCopilotProvider_IsIgnoredOrRejected |
AgentFactoryTests |
Non-Copilot providers ignore the entry (or the loader/factory rejects it), per the chosen semantics. |
CopilotSdkChatClient_BuildSessionConfig_NoPolicy_LeavesFieldsUnset |
CopilotSdkChatClientTests |
With builtinToolPolicy == null, neither AvailableTools nor ExcludedTools is touched. |
CopilotSdkChatClient_BuildSessionConfig_AvailablePolicyList_SetsAvailableTools |
CopilotSdkChatClientTests |
With policy AvailableTools = ["builtin:a","builtin:b","custom:*","mcp:*"], sessionConfig.AvailableTools equals exactly that list. |
CopilotSdkChatClient_BuildSessionConfig_AvailablePolicyCustomMcpOnly_KeepsNonBuiltinTools |
CopilotSdkChatClientTests |
With policy AvailableTools = ["custom:*","mcp:*"] (the empty-built-in-tools case), sessionConfig.AvailableTools equals exactly that list — asserts the built-in-empty path still passes custom/MCP through. |
CopilotSdkChatClient_BuildSessionConfig_ExcludedPolicyStar_SetsExcludedToolsToBuiltinStar |
CopilotSdkChatClientTests |
With policy ExcludedTools = ["builtin:*"], sessionConfig.ExcludedTools equals ["builtin:*"]. |
CopilotSdkChatClient_BuildSessionConfig_BothSlotsSet_AppliesBoth |
CopilotSdkChatClientTests |
With both AvailableTools and ExcludedTools non-null on the policy, both are applied to sessionConfig. |
CopilotSdkChatClient_BuildResumeSessionConfig_AppliesSamePolicy |
CopilotSdkChatClientTests |
The resume path (~L314–360) applies the same AvailableTools / ExcludedTools mapping as the fresh-session path across all forms. |
AgentDefinitionJsonSchema_GithubCliBuiltinToolsEntry_RoundTrips |
AgentDefinitionJsonSchemaTests |
The schema accepts and round-trips a tools[] entry with kind: "github-cli-builtin-tools" and any combination of available-tools / excluded-tools selector objects — validating against the new $defs/githubCliBuiltinToolsTool subschema and deserialising into a strongly-typed GitHubCliBuiltinToolsTool instance whose AvailableTools / ExcludedTools are BuiltinToolSet records (not IDictionary<string, object>). |
AgentDefinitionToolExtractor_GithubCliBuiltinToolsEntry_IsExtractedAsTypedSubclass |
AgentDefinitionToolExtractorTests |
The extractor surfaces the entry as a GitHubCliBuiltinToolsTool (subclass of AgentSchema.CustomTool), exposing typed AvailableTools, ExcludedTools, and ClientMode properties for downstream consumption by AgentFactory. |
AgentFactory_AvailableToolsMcpStar_MapsToMcpStarWithNoAutoAppend |
AgentFactoryTests |
The MCP-only allowlist recipe: { "available-tools": { "tools": ["mcp:*"] } } produces SessionConfig.AvailableTools == ["mcp:*"] — no custom:* auto-appended. Regression guard for the source-qualified-suppresses-auto-append rule. |
AgentFactory_AvailableToolsMcpStarAndCustomStar_PassesThroughVerbatim |
AgentFactoryTests |
{ "available-tools": { "tools": ["mcp:*","custom:*"] } } produces SessionConfig.AvailableTools == ["mcp:*","custom:*"] — verbatim; no auto-append; no builtin:* injected. |
AgentFactory_AvailableToolsMixedBareAndSourceQualified_PrefixesBareOnly |
AgentFactoryTests |
{ "available-tools": { "tools": ["a","mcp:*"] } } produces SessionConfig.AvailableTools == ["builtin:a","mcp:*"] — bare "a" is builtin:-prefixed; presence of a source-qualified pattern suppresses the custom:*/mcp:* auto-append. |
AgentFactory_AvailableToolsBareNamesOnly_AutoAppendsCustomAndMcp |
AgentFactoryTests |
Existing rule preserved: bare-only { "available-tools": { "tools": ["a","b"] } } produces ["builtin:a","builtin:b","custom:*","mcp:*"]. Regression guard against accidental suppression when no source-qualified pattern is present. |
AgentFactory_ExcludedToolsBuiltinStar_MapsToBuiltinStarVerbatim |
AgentFactoryTests |
The denylist recipe: { "excluded-tools": { "tools": ["builtin:*"] } } produces SessionConfig.ExcludedTools == ["builtin:*"] (source-qualified passes through the excluded slot unchanged; no builtin: re-prefixing). |
AgentFactory_ExcludedToolsMcpSpecificWireName_PassesThroughVerbatim |
AgentFactoryTests |
{ "excluded-tools": { "tools": ["mcp:github-list_issues"] } } produces SessionConfig.ExcludedTools == ["mcp:github-list_issues"]. |
AgentFactory_ClientModeEmpty_WithAvailableTools_ConstructsClientWithEmptyMode |
AgentFactoryTests |
{ "client-mode": "empty", "available-tools": { "tools": ["mcp:*"] } } yields a CopilotBuiltinToolPolicy with ClientMode = CopilotClientMode.Empty, and the CopilotClient backing the resulting CopilotSdkChatClient is constructed with CopilotClientOptions.Mode = Empty (and BaseDirectory or SessionFs set). |
AgentFactory_ClientModeCopilotCli_IsDefault |
AgentFactoryTests |
Omitting client-mode or setting it to "copilot-cli" yields ClientMode = CopilotClientMode.CopilotCli. Regression guard for the default. |
AgentFactory_ClientModeEmpty_WithoutAvailableTools_Throws |
AgentFactoryTests |
{ "client-mode": "empty" } with no available-tools (or with available-tools: { "tools": [] }) throws with a message stating that Empty mode requires a non-empty available-tools. |
AgentFactory_ClientModeEmpty_WithoutBaseDirectoryOrSessionFs_Throws |
AgentFactoryTests |
Empty mode requires BaseDirectory or SessionFs; if neither can be resolved from the agent's provider/account context, agent-load throws with a message naming the missing prerequisite. |
AgentFactory_ClientMode_InvalidValue_Throws |
AgentFactoryTests |
{ "client-mode": "off" } (or any string other than empty/copilot-cli, or a non-string type) throws with a message enumerating the accepted values. |
AgentFactory_ClientMode_ClientCachingKeyedOnMode |
AgentFactoryTests |
Two agents differing only in client-mode do NOT share the same CopilotClient — the client-construction/caching path is keyed on Mode (and on BaseDirectory/SessionFs). Regression guard for the architectural implication called out in the design. |
CopilotSdkChatClient_BuildSessionConfig_EmptyMode_RequiresAvailableTools |
CopilotSdkChatClientTests |
Under ClientMode = Empty, BuildSessionConfig (and BuildResumeSessionConfig) always sets SessionConfig.AvailableTools (never leaves it null) — mirrors the SDK's Empty-mode requirement that every session opts in. |
Expected Documentation
The implementation is expected to update the following markdown files in features\Phantom.Workspaces.Data.Core\JsonEntities\documentation\ (and, for research history, features\docs\research\AgentSchema-tool-kinds.md). This section captures the expected outputs of the implementation — do not edit these files as part of the design; they are updated when the code lands.
| File |
What it should describe |
agent-options-tools.md |
Add a section for the github-cli-builtin-tools CustomTool kind: the two selector slots (available-tools, excluded-tools), the three selector forms ({ tools: [...] }, { isolated: true }, and the wildcard "*"), the bare-vs-source-qualified naming rules and the resulting auto-append behaviour, the "MCP-only agent" recipe in both its allowlist and denylist forms, and the client-mode field with the Empty-mode preconditions. Include a JSON example table matching the "JSON examples" section of this issue. |
agent-options-overview.md |
Add a bullet cross-referencing github-cli-builtin-tools as the provider-specific configuration surface for github-copilot / github-copilot-subagent agents, and note that client-mode: empty selects the SDK's Empty client mode (a client-level property, not a session tweak). |
agent-configuration.md |
Document the canonical locked-down configuration recipe (client-mode: empty + available-tools: { "tools": ["mcp:*"] }), calling out that Empty mode + MCP-only allowlist is the recommended pattern for safety-sensitive agents. Note the interlock between client-mode: empty and the required non-empty available-tools. |
features\docs\research\AgentSchema-tool-kinds.md |
Add the github-cli-builtin-tools kind to the research inventory of recognised tool kinds. Record the SDK surface it maps to (SessionConfig.AvailableTools / SessionConfig.ExcludedTools / CopilotClientOptions.Mode) and the SDK version pin (GitHub.Copilot.SDK 1.0.8). |
Summary
The GitHub Copilot CLI SDK chat agent path (
CopilotSdkChatClient— the Copilot-SDK-backed chat client wired by thegithub-copilotandgithub-copilot-subagentproviders inAgentFactory) currently has no mechanism to control which Copilot CLI built-in tools an agent gets. Every session created throughBuildSessionConfig/BuildResumeSessionConfiginherits the SDK's default tool set (all built-ins). Restricted or safety-sensitive agents (sandboxed sub-agents, review-only agents, agents that should route exclusively to user-supplied MCP tools) cannot opt out, and there is no way to whitelist just the isolated set or to exclude specific tools.This bug adds a
github-cli-builtin-toolsCustomTool entry on theAgentDefinitionwhoseoptionsobject exposes:available-toolsandexcluded-tools— mapping natively ontoSessionConfig.AvailableToolsandSessionConfig.ExcludedToolson the pinnedGitHub.Copilot.SDK1.0.8. Selector entries accept bare built-in names (auto-prefixed tobuiltin:<name>) as well as explicit source-qualified patterns such asmcp:*,custom:*,builtin:*, ormcp:<wire-name>— which flow through to the SDK verbatim.client-modefield opting an agent into the SDK'sCopilotClientMode.Empty(a client-construction-time property, NOT a session field). This is the canonical way to build "locked-down" agents that expose nothing by default and route only to whatavailable-toolsexplicitly allows — see the worked "MCP-only" recipe below.The reusable selector — three forms (post-simplification)
A
BuiltinToolSetselector is a small JSON object with one of these shapes:{ "tools": ["*"] }"*"is a normal member oftools{ "tools": ["tool1","tool2"] }{ "isolated": true }GitHub.Copilot.BuiltInTools.Isolated){ "tools": [] }The selector is essentially
{ tools: string[] }OR{ isolated: true }. There is no longer a dedicatedallboolean — the wildcard"*"collapses into thetoolslist and covers form #1 as an ordinary token. Nullability of the deserialisedToolsproperty (seeBuiltinToolSetrecord below) distinguishes form #4 (Tools = []) from form-#3-with-Tools-absent (Tools = null,Isolated = true).Two slots — same selector shape applied to both
The
github-cli-builtin-toolstool'soptionsobject has two optional slots, each of which — when present — is aBuiltinToolSetselector:{ "kind": "github-cli-builtin-tools", "available-tools": { /* selector */ }, // optional "excluded-tools": { /* selector */ } // optional }Owner-supplied examples showing the wildcard:
{ "kind": "github-cli-builtin-tools", "available-tools": { "tools": ["*"] } } { "kind": "github-cli-builtin-tools", "excluded-tools": { "tools": ["*"] } } { "kind": "github-cli-builtin-tools", "excluded-tools": { "isolated": true } }Both slots may be present simultaneously. Absent slots leave the corresponding SDK field untouched (SDK default). Omitting the
github-cli-builtin-toolsentry entirely leaves both fields untouched, i.e. SDK default = all built-ins.Precedence within a selector
Highest wins:
tools(any list, including[]) — most specific.isolated: true.The factory logs a warning if it detects both
toolsandisolatedon the same selector, and appliestools > isolated.Combined-slot semantics
Both slots are independent SDK fields on the underlying
SessionConfig. The SDK's own filter appliesAvailableTools(allow-list) first, then removes anything named inExcludedTools. Documented equivalent behaviour: final built-in tool set =AvailableTools \ ExcludedTools(AND semantics).Direct answers to owner questions
GitHub.Copilot.SDK1.0.8 exposes:SessionConfig.AvailableTools : IList<string>— allow-list of source-qualified tool names.null(unset) ⇒ SDK default (no filter). Empty list ⇒ no tools included.SessionConfig.ExcludedTools : IList<string>— deny-list of source-qualified tool names.GitHub.Copilot.BuiltInTools.Isolated : IReadOnlyList<string>— the canonical "isolated" built-in tool set. Observed values in 1.0.8:ask_user, task_complete, exit_plan_mode, task, read_agent, write_agent, list_agents, send_inbox, context_board, skill(10 entries).GitHub.Copilot.ToolSet— aList<string>-like builder withAddBuiltIn(string),AddBuiltIn(IEnumerable<string>),AddMcp(string),AddCustom(string). Each method prepends a source prefix to the tool name:AddBuiltIn("shell") → "builtin:shell",AddBuiltIn("*") → "builtin:*",AddMcp("*") → "mcp:*".ResumeSessionConfigexposes the identicalAvailableTools/ExcludedTools/ExcludedBuiltInAgentsproperties.Directory.Packages.propspinsGitHub.Copilot.SDK1.0.8, which already contains all of the above. No package bump required."*"wildcard? — Yes, but only in source-qualified form, i.e."builtin:*"/"mcp:*"/"custom:*". A bare"*"inAvailableTools/ExcludedToolsis explicitly rejected by the SDK: verified from a string literal embedded inGitHub.Copilot.SDK.dll1.0.8:Root Cause / Current State
1. Session configuration only adds user tools; it never gates SDK built-in defaults.
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs:BuildSessionConfig(~L260–306):BuildResumeSessionConfig(~L314–360): same shape.Neither method ever sets
AvailableTools/ExcludedTools, so the SDK falls back to its full default built-in set.2.
AgentFactorynever reads a tool-policy entry from theAgentDefinition.Phantom.Workspaces.Llm.Core/AgentFactory.cs:CreateGitHubCopilotClient(~L798–841) constructsnew CopilotSdkChatClient(modelId, displayName, gitHubToken, ...)at ~L829 with no tool-policy parameter.CreateGitHubCopilotByokClient(~L847–895) — constructor call at ~L883.new CopilotSdkChatClient(...)sites at ~L933 and ~L989 are identical.ExtractTools(~L148–151) returns(agent as PromptAgent)?.Tools ?? []with no type filtering; entries flow intoToolsetFactoryfor MCP/agent-definition handling and are not forwarded toCopilotSdkChatClient.3.
AgentDefinitionJSON schema — the generictoolsubschema already accepts arbitrary properties.Phantom.Workspaces.Llm.Core/JsonSchemas/AgentDefinition.json:tools[](~L129–143) is ananyOfunion ofmcpTool/tool/agentDefinitionTool.$defs/toolsubschema (~L151–172) setsadditionalProperties: true(~L153), which is why an unrecognisedkindcurrently validates trivially.allowedTools(~L269–274) lives inside mcpTool and gates MCP-server tools; it does not affect Copilot CLI built-in tools.alwaysRequireApprovalTools/neverRequireApprovalToolsonly tune approval prompts for tools already exposed.This design adds a dedicated
$defs/githubCliBuiltinToolsToolsubschema withkind: { const: "github-cli-builtin-tools" }and fully-typedavailable-tools,excluded-tools, andclient-modeproperties, and inserts it intotools[]'sanyOfbefore the generic$defs/tool. To keep the shape strict (so a malformedgithub-cli-builtin-toolsentry cannot silently fall through to the permissive generic tool), the generic$defs/toolis amended to exclude this specifickind— see "Schema-level enforcement" below. This is the owner-preferred approach: the schema is the authoritative shape gate, so an incorrectly-generated definition fails at load/validate time rather than at first session build.4. Copilot SDK 1.0.8 surface — verified by reflection and DLL string extraction.
Verified by loading
%USERPROFILE%\.nuget\packages\github.copilot.sdk\1.0.8\lib\net8.0\GitHub.Copilot.SDK.dll. On bothGitHub.Copilot.SessionConfigandGitHub.Copilot.ResumeSessionConfig:ToolsICollection<Microsoft.Extensions.AI.AIFunctionDeclaration>AvailableToolsIList<string>null(unset) ⇒ no filter (SDK default). Empty list ⇒ no tools. Bare"*"is rejected.ExcludedToolsIList<string>null(unset) ⇒ no exclusion.ExcludedBuiltInAgentsIList<string>OnPermissionRequestFunc<PermissionRequest, ..., PermissionDecision>Public
GitHub.Copilot.BuiltInTools(static class): sole public static member isIsolated : IReadOnlyList<string>(10 entries:ask_user, task_complete, exit_plan_mode, task, read_agent, write_agent, list_agents, send_inbox, context_board, skill). There is noAll/Default/Namesenumeration — this does not matter for the current design, because the wildcard"builtin:*"covers the "everything" case natively viaExcludedTools.Public
GitHub.Copilot.ToolSet(implementsIList<string>): constructor.ctor(), methodsAddBuiltIn(string) → ToolSet,AddBuiltIn(IEnumerable<string>) → ToolSet,AddMcp(string) → ToolSet,AddCustom(string) → ToolSet. Runtime observation:new ToolSet().AddBuiltIn("*")yields the single element"builtin:*";AddBuiltIn(BuiltInTools.Isolated)yieldsbuiltin:ask_user,builtin:task_complete, …. String literal embedded in the DLL:Also embedded (Empty-mode diagnostic):
Together these confirm: (a) the SDK does natively understand
"*"as a wildcard, but only source-qualified; (b)ToolSetis the recommended builder for the source-prefixed strings thatAvailableTools/ExcludedToolsaccept.5.
AgentSchema.CustomTooldeserialisation — typed subclass, not the open Options bag.AgentSchema.CustomToolcurrently exposesKind,Name,Description,ShorthandProperty,Connection,IList<Binding> Bindings, and an openIDictionary<string, object> Optionsbag (precedent: thechat-historyCustomTool atAgentFactory.cs:581–586readsOptions["connection"] is IDictionary<string, object> connectionDict).For this feature we prefer typed JSON deserialisation over the open Options bag (owner directive — the policy shape must be a first-class deserialisable type, not
objectfished out of a dictionary). Concretely: introduce aGitHubCliBuiltinToolsToolsubclass ofCustomToolwith typed propertiesAvailableTools : BuiltinToolSet?,ExcludedTools : BuiltinToolSet?,ClientMode : CopilotClientMode, deserialised via akind-discriminatedSystem.Text.Jsonpolymorphic converter onCustomTool(or, equivalently, anAgentSchema.ToolConverterthat dispatches onkindto the right subclass and falls through to the baseCustomToolfor unknown kinds).BuiltinToolSetis itself a typed record — see the model section below — with[JsonPropertyName("tools")] IReadOnlyList<string>? Toolsand[JsonPropertyName("isolated")] bool Isolated.This has several concrete benefits:
IDictionary<string, object>/IEnumerable<object>, nois IDictionary<string, object>checks, no.OfType<string>().ToArray()gymnastics.AgentFactoryreceives a strongly-typedGitHubCliBuiltinToolsTooland passes it (or a projectedCopilotBuiltinToolPolicyderived from it) directly toCopilotSdkChatClient.git grep GitHubCliBuiltinToolsToolfinds every producer and consumer, unlike an untyped string key"github-cli-builtin-tools"in anOptionsbag.The
Optionsbag on the baseCustomToolremains for genuinely open-ended kinds; new provider-specific kinds should follow theGitHubCliBuiltinToolsToolpattern (typed subclass + schema subschema).Affected Files
Directory.Packages.propsGitHub.Copilot.SDK1.0.8, which already exposesAvailableTools/ExcludedTools,BuiltInTools.Isolated, andToolSetwith source-qualified wildcards.Phantom.Workspaces.Llm.Core/JsonSchemas/AgentDefinition.json$defs/githubCliBuiltinToolsToolsubschema withkind: { const: "github-cli-builtin-tools" }, typedavailable-tools/excluded-toolsselectors ({ oneOf: [ {tools: string[]}, {isolated: bool} ] }), and a typedclient-mode: { enum: ["empty","copilot-cli"] }. Insert it intotools[]anyOfbefore$defs/tool. Amend the generic$defs/toolto excludekind == "github-cli-builtin-tools"(see "Schema-level enforcement") so a malformed entry cannot silently fall through to the permissive generic. Update thekindfield'sdescriptionto list the recognised kinds.AgentSchemapackageGitHubCliBuiltinToolsTool : CustomToolwith typed propertiesAvailableTools : BuiltinToolSet?,ExcludedTools : BuiltinToolSet?,ClientMode : CopilotClientMode = CopilotCli. Introduce the typed recordBuiltinToolSet { IReadOnlyList<string>? Tools; bool Isolated }. Wire akind-discriminatedSystem.Text.Jsonpolymorphic converter (or extend the existingCustomToolconverter) so"kind": "github-cli-builtin-tools"deserialises intoGitHubCliBuiltinToolsTooland unknown kinds continue to deserialise to the baseCustomTool(backwards-compatible).Phantom.Workspaces.Llm.Core/AgentFactory.csCreateGitHubCopilotClient(~L798–841) andCreateGitHubCopilotByokClient(~L847–895) — and the sibling sites at ~L933 and ~L989 — find the singleGitHubCliBuiltinToolsToolentry viapromptAgent.Tools.OfType<GitHubCliBuiltinToolsTool>().SingleOrDefault(), project it to aCopilotBuiltinToolPolicy(source-qualification and auto-append rules applied here), and pass it to theCopilotSdkChatClientconstructor. Skip / filter this subtype out ofExtractTools(~L148–151) so it isn't handed toToolsetFactory.Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.csCopilotBuiltinToolPolicy?(defaultnull= SDK defaults) via constructor; apply it inBuildSessionConfig(~L260–306) andBuildResumeSessionConfig(~L314–360) as a native passthrough ontoSessionConfig.AvailableToolsandSessionConfig.ExcludedTools. Custom tools (SessionConfig.Tools) and MCP tools continue to flow through unchanged.Phantom.Workspaces.Llm.Core.Tests/AgentFactoryTests.csPhantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientTests.csSessionConfig.AvailableTools/SessionConfig.ExcludedToolsshape for each form and slot.Phantom.Workspaces.Llm.Core.Tests/AgentDefinitionJsonSchema*Tests.cs$defs/githubCliBuiltinToolsToolsubschema, plus negative tests confirming that malformedgithub-cli-builtin-toolsentries do NOT fall through to the generic$defs/tool(thenotamendment on$defs/toolis what closes that hole).docs/design/github-copilot-provider-support.mdDesign / Fix (Canonical) — shared selector + two slots + native passthrough
Represent the built-in-tool policy as a CustomTool entry with
kind: "github-cli-builtin-tools"whoseoptionsbag carries two independent selector slots —available-toolsandexcluded-tools— each aBuiltinToolSetselector. Map both onto the pinned SDK'sSessionConfig.AvailableToolsandSessionConfig.ExcludedTools, with the SDK's own wildcard"builtin:*"used natively when the selector says"*".BuiltinToolSetselector — resolution tableA selector object is resolved to a
ResolvedToolSet— eitherAbsent("selector not present"), orConcrete(IReadOnlyList<string>)(a possibly-empty list of bare built-in tool names;"*"is a permitted member meaning "all built-ins"):{ "tools": ["*"] }Concrete(["*"])— the wildcard is a normal list member{ "tools": ["a","b"] }Concrete(["a","b"]){ "tools": ["*","x"] }Concrete(["*","x"])— permitted; the SDK seesbuiltin:*andbuiltin:x{ "isolated": true }Concrete(BuiltInTools.Isolated.ToArray()){ "tools": [] }Concrete([])— explicit empty{ }/ neither key setAbsent(with warning)AbsentPresence check on
Toolsuses null-vs-empty (selector.Tools is { } toolssucceeds for[]and fails for absent) so[]is meaningfully distinct from an absenttoolskey. Precedence when more than one key is set:tools > isolated.Selector-to-SDK mapping — per slot
Applied identically to
SessionConfigandResumeSessionConfig. Bare names (no:) are rewritten to their source-qualified form by prependingbuiltin:(equivalent to callingnew ToolSet().AddBuiltIn(name)per element). Source-qualified entries (containing:— e.g.mcp:*,custom:*,builtin:*,mcp:github-list_issues) flow through verbatim and suppress thecustom:*/mcp:*auto-append (see rule above).available-toolsselector →SessionConfig.AvailableTools:AvailableTools{ "tools": ["*"] }null) — see note below on why we do NOT populate["builtin:*"]on the allow-list.{ "tools": ["a","b"] }["builtin:a", "builtin:b", "custom:*", "mcp:*"]— bare-only ⇒custom:*andmcp:*auto-appended.{ "tools": ["a","*"] }["builtin:a", "builtin:*", "custom:*", "mcp:*"]— bare-only ⇒ auto-append still applies.{ "tools": ["mcp:*"] }["mcp:*"]— MCP-only (built-ins AND custom off). Source-qualified ⇒ no auto-append. See the "MCP-only agent" recipe.{ "tools": ["mcp:*","custom:*"] }["mcp:*","custom:*"]— MCP + custom only (built-ins off). Source-qualified ⇒ no auto-append.{ "tools": ["mcp:github-list_issues"] }["mcp:github-list_issues"]— a single MCP tool by its runtime canonical wire name.mcp:*matches ALL MCP servers; there is no server-scoped wildcard, so to restrict to one server list each tool individually.{ "tools": ["a","mcp:*"] }["builtin:a","mcp:*"]— mixed bare + source-qualified: bare"a"is prefixed; presence of the source-qualifiedmcp:*suppresses auto-append.{ "isolated": true }new ToolSet().AddBuiltIn(BuiltInTools.Isolated).AddCustom("*").AddMcp("*")→["builtin:ask_user", …, "custom:*", "mcp:*"]—isolatedis a bare-form shorthand, so auto-append applies.{ "tools": [] }["custom:*", "mcp:*"]— no built-ins, but custom and MCP tools remain reachable (empty list is treated as the bare-only case).excluded-toolsselector →SessionConfig.ExcludedTools:ExcludedTools{ "tools": ["*"] }["builtin:*"]— native SDK wildcard meaning "exclude all built-ins". No alias hack needed.{ "tools": ["a","b"] }["builtin:a", "builtin:b"]{ "isolated": true }new ToolSet().AddBuiltIn(BuiltInTools.Isolated)→["builtin:ask_user", …]{ "tools": [] }new List<string>()(exclude nothing — no-op)Combined slots: both slots may coexist. Final effective built-in tool set =
AvailableTools \ ExcludedTools. Absent slots do not touch the corresponding SDK field.Absent
github-cli-builtin-toolsentry: neither field touched ⇒ SDK default (all built-ins). Semantically equivalent to{ "available-tools": { "tools": ["*"] } }with noexcluded-toolsslot.Precedence within a selector (multiple keys set)
Highest wins:
tools(any list, including[]) — most specific.isolated: true.JSON examples
Worked recipe — "MCP-only agent" (disable all built-ins, keep only MCP server tools)
Users frequently want an agent that has no Copilot built-ins available and routes exclusively to the MCP servers the agent declares. There are two idiomatic expressions of this, and the semantic difference matters — both must be documented as first-class in the schema examples.
1. Allowlist form — strictly MCP-only (also drops custom/SDK tools)
{ "kind": "github-cli-builtin-tools", "available-tools": { "tools": ["mcp:*"] } }SessionConfig.AvailableToolsis a global allow-list across every source, setting it to["mcp:*"]means only tools whose source-qualified name matchesmcp:*are available. Allbuiltin:*are off; allcustom:*are off; only MCP tools remain.mcp:*suppresses thecustom:*auto-append — this is what makes the recipe actually "MCP-only" instead of "MCP + custom".2. Denylist form — turn off built-ins, keep MCP + any custom/SDK tools
{ "kind": "github-cli-builtin-tools", "excluded-tools": { "tools": ["builtin:*"] } }ExcludedTools = ["builtin:*"]starts from the SDK default (everything available across all sources) and removes everybuiltin:*entry. MCP tools and custom tools survive. Becausesession.createalways setstoolFilterPrecedence: "excluded", this is guaranteed to win overAvailableTools.Guidance
available-tools: { "tools": ["mcp:*"] }).excluded-tools: { "tools": ["builtin:*"] }).mcp:*matches all MCP servers configured for the agent. There is no server-scoped wildcard in the SDK. To restrict to one specific MCP server, enumerate each tool by its runtime canonical wire name (mcp:<wire-name>, e.g.mcp:github-list_issues) — this is derived from SDK sourcesToolSet.cs(AddBuiltIn→"builtin:{name}",AddCustom→"custom:{name}",AddMcp→"mcp:{toolName}";ValidateNameallows*or^[a-zA-Z0-9_-]+$) andTypes.cs:265-269,331-337.client-mode: "empty"(next section):{ "kind": "github-cli-builtin-tools", "client-mode": "empty", "available-tools": { "tools": ["mcp:*"] } }client-mode— opt intoCopilotClientMode.Empty(client-construction property)A new option on the
github-cli-builtin-toolsCustomTooloptionsbag opts an agent into the SDK's alternative client mode:{ "kind": "github-cli-builtin-tools", "client-mode": "empty" | "copilot-cli", "available-tools": { /* required if client-mode == "empty" */ }, "excluded-tools": { /* optional */ } }Default:
"copilot-cli"(identical to today's behaviour).SDK facts (verified in
C:\dev\microsoft\copilot-sdk\dotnet\src\Types.cs:249-345):enum CopilotClientMode { Empty, CopilotCli }. DefaultCopilotCli.Modelives onCopilotClientOptions(CLIENT-level, perCopilotClient), NOT onSessionConfig/ResumeSessionConfig. Selecting Empty is therefore a property of how theCopilotClientbacking this agent is constructed, not a per-session tweak.AgentFactory(and the client-construction / client-caching path — the fournew CopilotSdkChatClient(...)sites at ~L829, ~L883, ~L933, ~L989) must build (or select from a pool) aCopilotClientwhoseCopilotClientOptions.Mode = Emptyfor such definitions. If the current architecture reuses oneCopilotClientacross multiple agents, that reuse must be keyed onMode(and onBaseDirectory/SessionFs) so anEmpty-mode agent never accidentally shares aCopilotCli-mode client.Emptymode the SDK requires:CopilotClientOptions.BaseDirectoryorCopilotClientOptions.SessionFsset on the client ctor;AvailableToolssupplied on everySessionConfig(Empty exposes no tools by default — see the DLL diagnostic string quoted above under §4);session.createalways setstoolFilterPrecedence: "excluded"(soExcludedToolsstill wins overAvailableTools);COPILOT_DISABLE_KEYTAR=1is set.Placement rationale. Placing
client-modeon thegithub-cli-builtin-toolsCustomTool options (rather than as a top-levelAgentDefinitionfield or a separategithub-copilot-clienttool kind) is consistent with the rest of this design: this tool kit is already the single Copilot-SDK-provider-specific configuration surface on the agent, andclient-modeandavailable-toolsare inextricably linked (Empty mode requiresavailable-tools). Keeping them in the same JSON object keeps the interlock local, validatable in one pass, and discoverable by anyone reading the tool entry. A top-levelAgentDefinitionfield would split the interlock across two schema locations and cross the "tool policy lives intools[]" invariant (see Alternative D in Considered / Background).Fail-fast validation for
client-mode(in addition to the general validation table below):client-modeis a string of"empty"or"copilot-cli"false/null/other strings rejected.'client-mode' must be one of "empty" or "copilot-cli"; got <actual>.available-toolsis present and non-emptyavailable-tools: { "tools": [] }counts as empty for this rule (Empty mode with zero tools would be an unusable agent).'client-mode: empty' requires 'available-tools' to be present and non-empty; Empty mode exposes no tools by default so every session must opt in.BaseDirectoryorSessionFsis configuredAgentFactory's client-construction path is responsible for ensuring one of these is set on theCopilotClientOptionsused to build theCopilotClient. If neither can be resolved from the agent's provider/account context, reject at agent-load time.'client-mode: empty' requires the underlying CopilotClient to be constructed with BaseDirectory or SessionFs; neither could be resolved for this AgentDefinition.Natural pairing. Empty mode +
available-tools: { "tools": ["mcp:*"] }(see recipe above) is the canonical "locked-down, MCP-only" configuration: the SDK opens the client in Empty mode (nothing exposed by default), and each session opts into exactlymcp:*. Both requirements — non-emptyavailable-toolsand MCP-only surface — are satisfied simultaneously.AgentSchema types — JSON-deserialisable
CopilotBuiltinToolPolicymodel — projected view forCopilotSdkChatClientAgentFactorywiring — typed projection toCopilotBuiltinToolPolicyBecause
GitHubCliBuiltinToolsToolis a strongly-typed subclass ofCustomTool,AgentFactoryretrieves it directly with no dictionary unboxing:ExtractTools(~L148–151) is updated to skip entries of concrete typeGitHubCliBuiltinToolsToolsoToolsetFactorynever sees them.Schema-level enforcement (authoritative gate)
The
AgentDefinition.jsonschema is the authoritative shape gate forgithub-cli-builtin-toolsentries. This is the owner-preferred approach: an incorrectly-generated definition must fail JSON-schema validation at agent load, not silently mis-apply, and other tool kinds must keep their own permissive schemata.How it composes with
tools[]anyOf— no fragility. The trick that avoids the previously-fearedanyOf/if/thenordering problem is a two-part change that leaves other tool kinds' schemata untouched:Add
$defs/githubCliBuiltinToolsTool— a fully-typed subschema:Insert
githubCliBuiltinToolsToolfirst intools[]'sanyOf, and amend the generic$defs/toolto exclude this specifickind. The generic tool's own definition is unchanged for every other kind; it merely gains anotconstraint saying "I am not agithub-cli-builtin-tools":This closes the "silent fall-through to the permissive generic" hole: a malformed
github-cli-builtin-toolsentry cannot satisfy$defs/tool(blocked by thenot) and cannot satisfy$defs/githubCliBuiltinToolsTool(blocked byadditionalProperties: falseand the typedoneOfon the selector), soanyOffails and validation loudly rejects the definition. Other kinds keep the permissiveadditionalProperties: truebehaviour of the generic tool exactly as today.What the schema enforces (no runtime duplication):
available-tools,excluded-tools, andclient-modeare permitted top-level options (additionalProperties: false).builtinToolSet— exactly one oftoolsorisolated(oneOf), no other keys (additionalProperties: false),isolatedmust be the literaltrue(falseis meaningless).toolsis a string array; each element matches the wire-name pattern ("*"or^[a-zA-Z0-9_-]+$or a source-qualified^(builtin|custom|mcp):(\*|[a-zA-Z0-9_-]+)$).client-modeis one of"empty"/"copilot-cli".client-mode: "empty"requiresavailable-toolspresent and non-empty. Expressed via theif/theninside the subschema'sallOf.Cross-field runtime checks (belt-and-suspenders). A small
ValidateClientModeInterlock(GitHubCliBuiltinToolsTool)inAgentFactoryre-checks theclient-mode: empty ⇒ available-tools non-emptyinterlock (redundant with the schema, kept for defence-in-depth against callers that constructAgentDefinitionin-memory and bypass schema validation) and additionally enforces the environmental precondition that the SDK does not express: Empty mode requiresBaseDirectoryorSessionFsto be resolvable atCopilotClientconstruction time. These are the only rules that live in code — every wire-shape rule lives in the schema.Known-name allow-list — deliberately NOT enforced (schema or runtime). Neither layer hard-codes a known set of built-in tool names beyond the wire-name pattern above. Rationale: the SDK classifies built-in tool names at runtime (in
session.create) and the set of built-ins can change between SDK versions (GitHub.Copilot.SDKmay add new tools in future 1.0.x releases). Hard-coding a name allow-list would cause valid definitions to be rejected after an SDK bump. If a user misspells a tool name, the SDK itself will refuse to route calls to it — a benign failure mode that surfaces on first call.Validation error mapping. Because the schema does the shape enforcement, callers get standard JSON-schema errors with JSON-pointer paths (e.g.
/tools/2/available-tools/tools/0).AgentDefinitionValidationExceptionshould surface those verbatim (as elsewhere in the loader) so LLM-authored definitions receive the same actionable feedback humans do.CopilotSdkChatClientenforcement — native passthroughBoth
BuildSessionConfig(~L260–306) andBuildResumeSessionConfig(~L314–360) apply the same block after constructingsessionConfig.ResumeSessionConfigshares the identical property surface, so the code is symmetric:Considered / Background (NOT chosen)
Superseded — earlier four-form selector with a dedicated
allbooleanBefore the SDK's source-qualified wildcard
"builtin:*"was verified, the canonical selector had four forms with a dedicated boolean:{ "all": true }{ "tools": [...] }{ "isolated": true }{ "tools": [] }Precedence was
tools > isolated > all. Theallboolean was collapsed into thetoolslist once the wildcard"*"was confirmed: form #1{ "all": true }is now expressed as{ "tools": ["*"] }, and form #1 onavailable-toolscontinues to map to "leave unset" (the wildcard's canonical mapping — see design note).Superseded —
excluded-tools: { "all": true }alias hackUnder the four-form design,
excluded-tools: { "all": true }had no clean SDK translation becauseBuiltInToolsexposes no enumeration of all built-in tool names. The workaround was to treat it as a documented alias foravailable-tools: { "tools": [] }, settingSessionConfig.AvailableTools = new List<string>()on the available slot and leavingExcludedToolsuntouched — with a warning about the aliasing and an edge-case rule for what to do if both slots were present.This hack is now retired. With the source-qualified wildcard confirmed,
{ "excluded-tools": { "tools": ["*"] } }maps natively toExcludedTools = ["builtin:*"]— no cross-slot rewrite, no warning, no lost expressive power (MCP and custom tools remain reachable when only built-ins are excluded, which the old alias did not preserve). This is a strict semantic improvement over the alias.Alternative A — earlier include-only shape (
include-all/include/include-isolated)An even earlier canonical design expressed the policy as an include-only allow-list on the CustomTool
optionsbag:options.include-all: bool⇒AvailableTools = null(SDK default, all built-ins).options.include: string[]⇒AvailableTools = [...](empty list = no built-ins).options.include-isolated: bool⇒AvailableTools = BuiltInTools.Isolated.ToList().Rejected in favour of the reusable-selector-plus-two-slots shape because it could not express "exclude a specific tool from the SDK default set" without listing every remaining built-in by name (fragile as the SDK's default set evolves), used three parallel keys whose semantics were not reusable in an
excluded-toolsslot, and did not compose across allow + deny.Alternative B — earlier
disableAll/disabled(exclude-based) schemaAn even earlier design expressed the policy as two exclusion-shaped properties in the options bag:
options.disableAll: boolandoptions.disabled: string[]. Rejected because it only expressed the deny-list side; there was no natural way to express the isolated set, no way to allow-list a small set of tools, and no way to compose allow + deny.Alternative C — wrapper-layer
OnPermissionRequestclamp +SessionConfig.ToolswithholdingAn early proposal (from when the SDK surface was believed to lack native disable knobs) enforced the policy at the wrapper layer by composing
OnPermissionRequestto deny disabled built-in tool names. Rejected because pinned SDK 1.0.8 exposesAvailableTools/ExcludedTools(with source-qualified wildcards) directly (see §4), which the SDK applies before any tool reaches the model. AnOnPermissionRequestdeny still lets the model attempt the call and only refuses at invocation time. Preserved as the documented fallback should a future SDK regress or rename these fields.Alternative D — top-level
AgentDefinitionfieldsAn earlier proposal expressed the policy as top-level
AgentDefinitionfields (disableDefaultTools: bool,disabledDefaultTools: string[]). Rejected because tool policy is naturally scoped totools[]; expressing it as a tool entry keepsAgentDefinition's top-level surface small and consistent with how MCP allow-lists and agent-definition tools are modelled. Akind-discriminated tool entry also composes cleanly with future provider-specific tool policies (e.g. an analogousopenai-builtin-toolskind).Alternative E — dedicated
$defs/githubCliBuiltinToolsTooldiscriminated subschema (now the chosen approach)An intermediate proposal added a new
$defs/githubCliBuiltinToolsToolsubschema (withkind: { const: "github-cli-builtin-tools" }and typed properties) and inserted it into thetools[]anyOfbefore the generictool. It was briefly rejected out of concern that JSON SchemaanyOf/if/thencomposition would be fragile in downstream validators and would silently fall through to the permissive generic$defs/tool.Reinstated (owner directive — see "Schema-level enforcement" above). The fall-through concern is closed by amending the generic
$defs/toolwith anotconstraint that excludes this specifickind, soanyOfcannot silently accept a malformedgithub-cli-builtin-toolsentry via the generic branch. Every other kind's schema is unchanged. The typed subschema plus thenotamendment together make the JSON schema the authoritative shape gate; runtime validation is reduced to cross-field interlocks that the schema cannot express (see "Cross-field runtime checks").The considered-but-not-chosen designs are preserved so the alternatives aren't lost.
Expected Tests
Follow the
Subject_Scenario_ExpectedOutcomenaming already used inPhantom.Workspaces.Llm.Core.Tests(seeAgentFactoryTests.ConfigureChatOptions_MapsThinkingToReasoningEffortandAgentFactoryTests.CreateChatClient_GitHubCopilotProvider_ReturnsCopilotSdkClient).AgentFactory_AvailableToolsWildcard_LeavesAvailableToolsUnsetAgentFactoryTests{ "available-tools": { "tools": ["*"] } }produces a policy whoseAvailableToolsisnull, soSessionConfig.AvailableToolsis not touched (SDK default = all sources). Regression guard for the "do not populate["builtin:*"]alone" rule.AgentFactory_AvailableToolsList_SetsAvailableToolsAgentFactoryTests{ "available-tools": { "tools": ["read_agent","list_agents"] } }setsSessionConfig.AvailableTools == ["builtin:read_agent","builtin:list_agents","custom:*","mcp:*"]in exact order — the trailingcustom:*andmcp:*are auto-appended to preserve non-built-in tools.AgentFactory_AvailableToolsSpecificList_PreservesCustomAndMcpAgentFactoryTestsavailable-tools: { "tools": ["bash","edit"] }and an agent that also declares an MCP tool and a custom tool elsewhere in itsAgentDefinition.tools[], bothcustom:*andmcp:*are present inSessionConfig.AvailableToolsalongsidebuiltin:bash/builtin:edit, so the agent's own custom/MCP tools remain reachable.AgentFactory_AvailableToolsWildcardMixed_PassesThroughAgentFactoryTests{ "available-tools": { "tools": ["*","read_agent"] } }setsSessionConfig.AvailableTools == ["builtin:*","builtin:read_agent","custom:*","mcp:*"]— mixed lists containing"*"are passed through verbatim (author-explicit intent) withcustom:*/mcp:*still appended.AgentFactory_AvailableToolsIsolated_SetsAvailableToolsToIsolatedSetAgentFactoryTests{ "available-tools": { "isolated": true } }setsSessionConfig.AvailableToolsequal tonew ToolSet().AddBuiltIn(BuiltInTools.Isolated).AddCustom("*").AddMcp("*")(i.e.["builtin:ask_user", …, "custom:*", "mcp:*"]), element-wise.AgentFactory_AvailableToolsEmpty_SetsAvailableToolsToCustomAndMcpOnlyAgentFactoryTests{ "available-tools": { "tools": [] } }setsSessionConfig.AvailableTools == ["custom:*","mcp:*"]— no built-ins, but custom and MCP tools remain reachable.AgentFactory_ExcludedToolsWildcard_SetsExcludedToolsToBuiltinStarAgentFactoryTests{ "excluded-tools": { "tools": ["*"] } }setsSessionConfig.ExcludedTools == ["builtin:*"]— native SDK wildcard, no alias,AvailableToolsuntouched.AgentFactory_ExcludedToolsList_SetsExcludedToolsAgentFactoryTests{ "excluded-tools": { "tools": ["shell"] } }setsSessionConfig.ExcludedTools == ["builtin:shell"].AgentFactory_ExcludedToolsIsolated_SetsExcludedToolsToIsolatedSetAgentFactoryTests{ "excluded-tools": { "isolated": true } }setsSessionConfig.ExcludedToolsequal tonew ToolSet().AddBuiltIn(BuiltInTools.Isolated).AgentFactory_ExcludedToolsEmpty_LeavesExcludedToolsEmptyListAgentFactoryTests{ "excluded-tools": { "tools": [] } }setsSessionConfig.ExcludedToolsto a non-null, empty list (no-op deny).AgentFactory_AvailableAndExcludedCombined_AppliesBothAgentFactoryTests{ "available-tools": { "isolated": true }, "excluded-tools": { "tools": ["exit_plan_mode"] } }setsAvailableTools = ["builtin:ask_user", …, "custom:*", "mcp:*"]andExcludedTools = ["builtin:exit_plan_mode"]onSessionConfig.AgentFactory_NoBuiltinToolsEntry_LeavesDefaultsAgentFactoryTestsgithub-cli-builtin-toolsentry yields a null policy soSessionConfig.AvailableToolsandSessionConfig.ExcludedToolsremain unset (regression guard).AgentFactory_SelectorBothToolsAndIsolated_AppliesPrecedenceAgentFactoryTeststoolsandisolatedare set on the same selector, precedencetools > isolatedis applied and a warning is logged (this remains a warn, not a reject).AgentFactory_BuiltinToolsUnknownOptionKey_SchemaRejectsAgentDefinitionJsonSchemaTestsAgentDefinitionwhosegithub-cli-builtin-toolsentry contains an unrecognised key (e.g.{ "kind": "github-cli-builtin-tools", "disabledTools": [...] }) fails JSON-schema validation with a message naming the offending path. Enforced byadditionalProperties: falseon$defs/githubCliBuiltinToolsToolplus thenotamendment on$defs/toolthat prevents fall-through. Fail-fast LLM-safety guard.AgentFactory_BuiltinToolsSelectorNeitherToolsNorIsolated_ThrowsAgentFactoryTestsavailable-tools: {}) throws with a message stating that the selector must set eithertoolsorisolated. Replaces the earlier "empty selector is ignored with warning" behaviour — reject is safer for LLM-authored definitions.AgentFactory_BuiltinToolsSelectorUnknownKey_ThrowsAgentFactoryTestsavailable-tools: { "include": ["x"] }) throws naming the offending key.AgentFactory_BuiltinToolsSelectorWithBadToolName_ThrowsAgentFactoryTeststoolslist containing a name with disallowed characters (e.g."builtin:shell"— already-prefixed — or"foo bar") throws with a message quoting the offending name and the required regex. EnforcesToolSet.ValidateNamesemantics up-front.AgentFactory_BuiltinToolsSelectorToolsNotArray_ThrowsAgentFactoryTeststoolsis present but not a JSON array (e.g. a string), throws with a message statingtoolsmust be an array of strings.AgentFactory_BuiltinToolsSelectorToolsElementNotString_ThrowsAgentFactoryTeststoolscontains a non-string element (e.g.["shell", 42]), throws naming the offending index and its actual type.AgentFactory_BuiltinToolsIsolatedNonBool_ThrowsAgentFactoryTestsisolatedis present but not a boolean (e.g."true",1), throws with a message statingisolatedmust be a boolean.AgentFactory_BuiltinToolsSlotNotObject_ThrowsAgentFactoryTestsavailable-tools: ["shell"]), throws with a message stating the slot must be a selector object.AgentFactory_BuiltinToolsEntry_IsNotForwardedToToolsetFactoryAgentFactoryTestsgithub-cli-builtin-toolsentry is filtered out ofExtractTools(~L148–151) and never reachesToolsetFactory.AgentFactory_BuiltinToolsEntry_OnNonCopilotProvider_IsIgnoredOrRejectedAgentFactoryTestsCopilotSdkChatClient_BuildSessionConfig_NoPolicy_LeavesFieldsUnsetCopilotSdkChatClientTestsbuiltinToolPolicy == null, neitherAvailableToolsnorExcludedToolsis touched.CopilotSdkChatClient_BuildSessionConfig_AvailablePolicyList_SetsAvailableToolsCopilotSdkChatClientTestsAvailableTools = ["builtin:a","builtin:b","custom:*","mcp:*"],sessionConfig.AvailableToolsequals exactly that list.CopilotSdkChatClient_BuildSessionConfig_AvailablePolicyCustomMcpOnly_KeepsNonBuiltinToolsCopilotSdkChatClientTestsAvailableTools = ["custom:*","mcp:*"](the empty-built-in-tools case),sessionConfig.AvailableToolsequals exactly that list — asserts the built-in-empty path still passes custom/MCP through.CopilotSdkChatClient_BuildSessionConfig_ExcludedPolicyStar_SetsExcludedToolsToBuiltinStarCopilotSdkChatClientTestsExcludedTools = ["builtin:*"],sessionConfig.ExcludedToolsequals["builtin:*"].CopilotSdkChatClient_BuildSessionConfig_BothSlotsSet_AppliesBothCopilotSdkChatClientTestsAvailableToolsandExcludedToolsnon-null on the policy, both are applied tosessionConfig.CopilotSdkChatClient_BuildResumeSessionConfig_AppliesSamePolicyCopilotSdkChatClientTestsAvailableTools/ExcludedToolsmapping as the fresh-session path across all forms.AgentDefinitionJsonSchema_GithubCliBuiltinToolsEntry_RoundTripsAgentDefinitionJsonSchemaTeststools[]entry withkind: "github-cli-builtin-tools"and any combination ofavailable-tools/excluded-toolsselector objects — validating against the new$defs/githubCliBuiltinToolsToolsubschema and deserialising into a strongly-typedGitHubCliBuiltinToolsToolinstance whoseAvailableTools/ExcludedToolsareBuiltinToolSetrecords (notIDictionary<string, object>).AgentDefinitionToolExtractor_GithubCliBuiltinToolsEntry_IsExtractedAsTypedSubclassAgentDefinitionToolExtractorTestsGitHubCliBuiltinToolsTool(subclass ofAgentSchema.CustomTool), exposing typedAvailableTools,ExcludedTools, andClientModeproperties for downstream consumption byAgentFactory.AgentFactory_AvailableToolsMcpStar_MapsToMcpStarWithNoAutoAppendAgentFactoryTests{ "available-tools": { "tools": ["mcp:*"] } }producesSessionConfig.AvailableTools == ["mcp:*"]— nocustom:*auto-appended. Regression guard for the source-qualified-suppresses-auto-append rule.AgentFactory_AvailableToolsMcpStarAndCustomStar_PassesThroughVerbatimAgentFactoryTests{ "available-tools": { "tools": ["mcp:*","custom:*"] } }producesSessionConfig.AvailableTools == ["mcp:*","custom:*"]— verbatim; no auto-append; nobuiltin:*injected.AgentFactory_AvailableToolsMixedBareAndSourceQualified_PrefixesBareOnlyAgentFactoryTests{ "available-tools": { "tools": ["a","mcp:*"] } }producesSessionConfig.AvailableTools == ["builtin:a","mcp:*"]— bare"a"isbuiltin:-prefixed; presence of a source-qualified pattern suppresses thecustom:*/mcp:*auto-append.AgentFactory_AvailableToolsBareNamesOnly_AutoAppendsCustomAndMcpAgentFactoryTests{ "available-tools": { "tools": ["a","b"] } }produces["builtin:a","builtin:b","custom:*","mcp:*"]. Regression guard against accidental suppression when no source-qualified pattern is present.AgentFactory_ExcludedToolsBuiltinStar_MapsToBuiltinStarVerbatimAgentFactoryTests{ "excluded-tools": { "tools": ["builtin:*"] } }producesSessionConfig.ExcludedTools == ["builtin:*"](source-qualified passes through the excluded slot unchanged; nobuiltin:re-prefixing).AgentFactory_ExcludedToolsMcpSpecificWireName_PassesThroughVerbatimAgentFactoryTests{ "excluded-tools": { "tools": ["mcp:github-list_issues"] } }producesSessionConfig.ExcludedTools == ["mcp:github-list_issues"].AgentFactory_ClientModeEmpty_WithAvailableTools_ConstructsClientWithEmptyModeAgentFactoryTests{ "client-mode": "empty", "available-tools": { "tools": ["mcp:*"] } }yields aCopilotBuiltinToolPolicywithClientMode = CopilotClientMode.Empty, and theCopilotClientbacking the resultingCopilotSdkChatClientis constructed withCopilotClientOptions.Mode = Empty(andBaseDirectoryorSessionFsset).AgentFactory_ClientModeCopilotCli_IsDefaultAgentFactoryTestsclient-modeor setting it to"copilot-cli"yieldsClientMode = CopilotClientMode.CopilotCli. Regression guard for the default.AgentFactory_ClientModeEmpty_WithoutAvailableTools_ThrowsAgentFactoryTests{ "client-mode": "empty" }with noavailable-tools(or withavailable-tools: { "tools": [] }) throws with a message stating that Empty mode requires a non-emptyavailable-tools.AgentFactory_ClientModeEmpty_WithoutBaseDirectoryOrSessionFs_ThrowsAgentFactoryTestsBaseDirectoryorSessionFs; if neither can be resolved from the agent's provider/account context, agent-load throws with a message naming the missing prerequisite.AgentFactory_ClientMode_InvalidValue_ThrowsAgentFactoryTests{ "client-mode": "off" }(or any string other thanempty/copilot-cli, or a non-string type) throws with a message enumerating the accepted values.AgentFactory_ClientMode_ClientCachingKeyedOnModeAgentFactoryTestsclient-modedo NOT share the sameCopilotClient— the client-construction/caching path is keyed onMode(and onBaseDirectory/SessionFs). Regression guard for the architectural implication called out in the design.CopilotSdkChatClient_BuildSessionConfig_EmptyMode_RequiresAvailableToolsCopilotSdkChatClientTestsClientMode = Empty,BuildSessionConfig(andBuildResumeSessionConfig) always setsSessionConfig.AvailableTools(never leaves itnull) — mirrors the SDK's Empty-mode requirement that every session opts in.Expected Documentation
The implementation is expected to update the following markdown files in
features\Phantom.Workspaces.Data.Core\JsonEntities\documentation\(and, for research history,features\docs\research\AgentSchema-tool-kinds.md). This section captures the expected outputs of the implementation — do not edit these files as part of the design; they are updated when the code lands.agent-options-tools.mdgithub-cli-builtin-toolsCustomTool kind: the two selector slots (available-tools,excluded-tools), the three selector forms ({ tools: [...] },{ isolated: true }, and the wildcard"*"), the bare-vs-source-qualified naming rules and the resulting auto-append behaviour, the "MCP-only agent" recipe in both its allowlist and denylist forms, and theclient-modefield with the Empty-mode preconditions. Include a JSON example table matching the "JSON examples" section of this issue.agent-options-overview.mdgithub-cli-builtin-toolsas the provider-specific configuration surface forgithub-copilot/github-copilot-subagentagents, and note thatclient-mode: emptyselects the SDK's Empty client mode (a client-level property, not a session tweak).agent-configuration.mdclient-mode: empty+available-tools: { "tools": ["mcp:*"] }), calling out that Empty mode + MCP-only allowlist is the recommended pattern for safety-sensitive agents. Note the interlock betweenclient-mode: emptyand the required non-emptyavailable-tools.features\docs\research\AgentSchema-tool-kinds.mdgithub-cli-builtin-toolskind to the research inventory of recognised tool kinds. Record the SDK surface it maps to (SessionConfig.AvailableTools/SessionConfig.ExcludedTools/CopilotClientOptions.Mode) and the SDK version pin (GitHub.Copilot.SDK1.0.8).