Skip to content

Multitenancy hardening: Client Mode#1428

Draft
SteveSandersonMS wants to merge 10 commits into
mainfrom
stevesandersonms/session-profiles
Draft

Multitenancy hardening: Client Mode#1428
SteveSandersonMS wants to merge 10 commits into
mainfrom
stevesandersonms/session-profiles

Conversation

@SteveSandersonMS
Copy link
Copy Markdown
Contributor

@SteveSandersonMS SteveSandersonMS commented May 26, 2026

Why

Today, instantiating CopilotClient gives you the full Copilot CLI
experience — every built-in tool, every host-side capability, all of the
defaults that make sense for a local developer. For SDK consumers
building multi-tenant agents (servers running untrusted prompts on
behalf of many users), those defaults are dangerous: a tool like bash
or web_fetch shouldn't be reachable unless the host explicitly
opts in.

Rather than make the runtime guess what an app wants, we let the SDK
declare the agent shape up front. Two pieces:

  1. mode: "empty" says "start from nothing, I'll declare what's
    safe." It refuses to construct without a baseDirectory (or
    sessionFs), and refuses to create sessions without an explicit
    availableTools.
  2. ToolSet + source-qualified patterns (builtin:*, mcp:*,
    custom:*, plus exact names) make those declarations ergonomic
    without surprising blast radius. Bare "*" is now rejected with a
    pointer to the qualified forms — we don't want anyone accidentally
    typing one character and re-enabling shell access.

What's new on the SDK surface

import {
    CopilotClient,
    BuiltInTools,
    ToolSet,
    approveAll,
} from "@github/copilot";

const client = new CopilotClient({
    mode: "empty",                  // ← opt in to safe-by-default
    baseDirectory: "/srv/agents",
});

const session = await client.createSession({
    onPermissionRequest: approveAll,
    availableTools: new ToolSet()
        .addBuiltIn(BuiltInTools.Isolated)   // curated set: no shell, no fs edit, no net
        .addMcp("github:*"),                 // every tool from the "github" MCP server
    excludedTools: ["mcp:github:delete_repository"],
    // toolFilterMode defaults to "denyPrecedence" in empty mode so the
    // exclude above wins over the includes.
});

availableTools and excludedTools now accept either a ToolSet or a
plain string[] of patterns. The same patterns flow straight through to
the runtime (which is mode-agnostic — see #7155 / #8760 for the
contract).

Tests

  • Unit: nodejs/test/toolSet.test.ts (18 tests) covers the builder,
    empty-mode validation, ToolSet → wire normalization, bare-*
    rejection, and toolFilterMode defaulting.
  • E2E: nodejs/test/e2e/mode_empty.e2e.test.ts (3 tests, with
    recorded CapiProxy snapshots) verifies what the LLM actually sees:
    • Isolated excludes shell / edit / grep / web_fetch.
    • builtin:* re-exposes the shell tool.
    • denyPrecedence (the empty-mode default) lets excludedTools
      subtract from availableTools.

All pass locally; CI will run the rest.

Back-compat

  • Default mode is "copilot-cli" (the existing behavior). Existing
    apps that don't set mode see no change.
  • availableTools / excludedTools previously accepted string[];
    they now also accept ToolSet. Existing call sites compile unchanged.

Follow-ups on this branch

  • .NET SDK
  • Go SDK
  • Java SDK
  • Python SDK
  • Rust SDK
  • Regenerate nodejs/src/generated/rpc.ts to include
    toolFilterMode (currently passed via the wire payload directly).

@SteveSandersonMS SteveSandersonMS changed the title Multitenancy hardening: Session profiles via Mode=empty (Node SDK) Multitenancy hardening: Client Mode May 26, 2026
@github-actions

This comment has been minimized.

Steve Sanderson and others added 3 commits May 27, 2026 12:58
Adds Node SDK surface for the multitenancy hardening work in
github/copilot-agent-runtime#7155 (runtime PR #8760).

- New `mode: "empty" | "copilot-cli"` on CopilotClientOptions; empty
  mode requires baseDirectory or sessionFs and rejects sessions
  without explicit availableTools.
- New ToolSet builder + BuiltInTools.Isolated constant for ergonomic,
  source-qualified tool patterns (builtin:*, mcp:*, custom:*).
- availableTools / excludedTools now accept ToolSet or string[]; bare
  "*" is rejected with a clear error pointing at the source-qualified
  forms.
- New toolFilterMode option ("allowPrecedence" | "denyPrecedence");
  empty mode defaults to denyPrecedence so apps can compose
  include+exclude.
- Unit tests (18) and e2e tests (3) including recorded CapiProxy
  snapshots.

Co-authored-by: Copilot <[email protected]>
The SDK no longer exposes 'toolFilterMode'. Every session.create / session.resume request now sends toolFilterMode: 'denyPrecedence' unconditionally, so SDK callers always get composable include+exclude semantics (a tool is enabled when it matches availableTools — or availableTools is unset — AND it does not match excludedTools).

Allowlist-precedence remains available on the runtime side as a CLI-only concession to legacy behavior; SDK consumers don't need it and the toggle was just extra surface area.

Co-authored-by: Copilot <[email protected]>
…recedence -> excluded

Mirrors the rename landed in the runtime PR. Also regenerates rpc.ts
to pick up the new toolFilterPrecedence field on SessionUpdateOptionsParams,
and renames the corresponding E2E capture snapshot.

Co-authored-by: Copilot <[email protected]>
@SteveSandersonMS SteveSandersonMS force-pushed the stevesandersonms/session-profiles branch from 7ee91d6 to a41b260 Compare May 27, 2026 12:03
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@SteveSandersonMS SteveSandersonMS force-pushed the stevesandersonms/session-profiles branch from 159647a to 7a4f8b5 Compare May 27, 2026 12:55
@github-actions

This comment has been minimized.

@SteveSandersonMS SteveSandersonMS force-pushed the stevesandersonms/session-profiles branch from 9bf763f to d58befa Compare May 27, 2026 13:05
@github-actions

This comment has been minimized.

Three deterministic tests using sendAndWait + element-name instructions:

- default: env_context stripped (ARGON)

- replace: caller content used verbatim (KRYPTON)

- append: caller instruction applied and env_context still stripped (XENON)

Co-authored-by: Copilot <[email protected]>
@github-actions

This comment has been minimized.

…KEYTAR in empty mode

- Add skipCustomInstructions, customAgentsLocalOnly, coauthorEnabled, manageScheduleEnabled to SessionConfigBase. App-supplied values now win over the empty-mode defaults that are otherwise forced via the post-create session.options.update patch. Also forwarded in copilot-cli mode when the app sets them.

- Set COPILOT_DISABLE_KEYTAR=1 on the runtime spawn env when mode === 'empty', so the runtime skips the process-wide system keychain (unsafe for multi-tenant hosts) and falls back to file-based credentials scoped to COPILOT_HOME.

- 31/31 unit tests passing (2 new covering the override and copilot-cli forwarding paths).

Co-authored-by: Copilot <[email protected]>
@github-actions

This comment has been minimized.

Port of nodejs/src/client.ts mode=empty work (commits 78e7280..61f8694)
to the .NET SDK.

- Add CopilotClientMode enum (Empty, CopilotCli) and Mode field on
  CopilotClientOptions; validation in constructor requires BaseDirectory,
  SessionFs, or UriRuntimeConnection when Mode=Empty.
- Add ToolSet builder and BuiltInTools.Isolated curated set in
  dotnet/src/ToolSet.cs. ToolSet inherits from List<string> so instances
  can be assigned directly to AvailableTools/ExcludedTools.
- Tool-filter resolution always emits toolFilterPrecedence=excluded on
  the wire (CreateSessionRequest/ResumeSessionRequest). Bare "*" rejected.
  Empty mode requires AvailableTools.
- Empty-mode safe defaults applied in Create/Resume:
  - environment_context stripped from system message
  - EnableSessionTelemetry=false (caller wins)
  - post-create session.options.update patch sets installedPlugins=[]
    plus 4 opt-back-in flags (SkipCustomInstructions=true,
    CustomAgentsLocalOnly=true, CoauthorEnabled=false,
    ManageScheduleEnabled=false), caller wins
  - COPILOT_DISABLE_KEYTAR=1 env in spawned CLI
- Add SessionConfigBase fields for the 4 opt-back-in flags.
- Add E2E tests at dotnet/test/E2E/ModeEmptyE2ETests.cs (6 tests) sharing
  recorded cassettes with the Node SDK under test/snapshots/mode_empty/.
- Regenerate dotnet/src/Generated/Rpc.cs against runtime branch schema to
  expose OptionsUpdateToolFilterPrecedence.
- Tidy: rename existing mode_empty cassettes to clean snake_case names
  shared across all language SDKs; update Node test titles to match.

Co-authored-by: Copilot <[email protected]>
Comment thread dotnet/src/Client.cs
Comment on lines +528 to +538
foreach (var entry in list)
{
if (entry == "*")
{
throw new ArgumentException(
$"Invalid {field} entry '*': there is no bare wildcard. " +
"Use `new ToolSet().AddBuiltIn(\"*\")`, `.AddMcp(\"*\")`, or " +
"`.AddCustom(\"*\")` to target a specific source.",
nameof(list));
}
}
Comment on lines +85 to +87
catch
{
}
Comment on lines +115 to +117
catch
{
}
Comment on lines +53 to +57
catch
{
// Some runs end the turn without producing a final assistant message;
// we only care about the tool surface the LLM was shown.
}
Comment on lines +85 to +87
catch
{
}
Comment on lines +115 to +117
catch
{
}
@github-actions
Copy link
Copy Markdown
Contributor

Cross-SDK Consistency Review

Node.js and .NET are consistently implemented — both have the full feature surface: mode: "empty" / CopilotClientMode.Empty, ToolSet builder, BuiltInTools constants, approveAll, and toolFilterMode/ToolFilterPrecedence defaulting. The API shape is parallel and accounts for language idioms correctly (camelCase vs PascalCase).

Generated-only changes in Go, Python, and Rust — these SDKs received the new toolFilterPrecedence wire type from schema generation, but the higher-level feature (mode, ToolSet, BuiltInTools) is not yet implemented. Java has no changes at all. This matches the follow-up checklist in the PR description.

Summary of missing implementations

SDK Status
Node.js ✅ Full implementation
.NET ✅ Full implementation
Go ⏳ Only generated RPC type (OptionsUpdateToolFilterPrecedence) — ClientMode, ToolSet, BuiltInTools not yet added
Python ⏳ Only generated RPC type — same gaps as Go
Rust ⏳ Only generated type — same gaps
Java ⏳ No changes yet

Since this is a draft PR and the author has already listed all four follow-ups, no blocking issues here. Just confirming the scope for visibility.

Generated by SDK Consistency Review Agent for issue #1428 · ● 2.7M ·

SteveSandersonMS and others added 3 commits May 27, 2026 15:32
Mirrors the C#/Node Mode=Empty implementation:
- ClientMode (ModeEmpty/ModeCopilotCli) + Mode field on ClientOptions
- ToolSet builder with AddBuiltIn/AddMcp/AddCustom + BuiltInToolsIsolated
- Tool-name charset validation (panics on /[^a-zA-Z0-9_-]/)
- NewClient validation: empty mode requires BaseDirectory, SessionFs, or
  UriConnection
- 4 opt-back-in fields on SessionConfig and ResumeSessionConfig
  (SkipCustomInstructions, CustomAgentsLocalOnly, CoauthorEnabled,
  ManageScheduleEnabled)
- ToolFilterPrecedence on createSessionRequest/resumeSessionRequest
- Mode helpers: resolveToolFilterOptions, systemMessageForMode (strips
  environment_context), applyConfigDefaultsForMode (telemetry off),
  updateSessionOptionsForMode (post-create patch)
- COPILOT_DISABLE_KEYTAR=1 in spawned runtime env when Mode=ModeEmpty
- toolset_test.go unit tests (16/16 passing)
- mode_empty_e2e_test.go reuses the shared test/snapshots/mode_empty/
  cassettes

Co-authored-by: Copilot <[email protected]>
…aults)

Mirrors the Node implementation from the same PR:

- CopilotClientMode = Literal["copilot-cli", "empty"], default "copilot-cli"
- ToolSet builder with add_builtin / add_mcp / add_custom; entries are
  source-qualified strings (builtin:*/mcp:*/custom:*) — no bare wildcard
- BUILTIN_TOOLS_ISOLATED — built-ins safe for single-session, no-host-state
  contexts (ask_user, task_complete, exit_plan_mode, subagent helpers, …)
- _CopilotClientOptions.mode + CopilotClient(mode=...) kwarg
- Empty mode validates base_directory or session_fs or URI connection at
  construction time
- create_session / resume_session: validate available_tools is set in empty
  mode; reject bare "*"; normalize ToolSet -> list[str]; transform
  system_message to strip environment_context; default
  enable_session_telemetry to False; always emit toolFilterPrecedence
  "excluded"
- 4 opt-back-in SessionConfig fields: skip_custom_instructions,
  custom_agents_local_only, coauthor_enabled, manage_schedule_enabled —
  applied via session.options.update after create/resume. installedPlugins
  is forced to [] in empty mode. Failure to apply the patch tears the
  session down so empty-mode callers never end up with a permissive session.
- Runtime spawn env: COPILOT_DISABLE_KEYTAR=1 when mode="empty"
- Exports new symbols from `copilot` package
- 40 unit tests in python/test_tool_set.py covering the builder and helpers

Co-authored-by: Copilot <[email protected]>
Ports the empty-mode SDK feature from Node (commits 78e7280..61f8694)
to the Rust SDK:

- Add `tool_filter_precedence: "excluded"` to SessionCreate/SessionResume
  wire payloads (always sent, matching Node).
- `create_session` / `resume_session`:
  - Reject empty mode when `available_tools` is unset.
  - Validate `available_tools` / `excluded_tools` reject bare "*".
  - Apply `system_message_for_mode` to strip `environment_context` in
    empty mode unless the caller has already overridden it.
  - Default `enable_session_telemetry = false` in empty mode when unset.
  - After session creation succeeds, send a `session.options.update`
    patch with safe defaults (skipCustomInstructions=true,
    customAgentsLocalOnly=true, coauthorEnabled=false,
    manageScheduleEnabled=false, installedPlugins=[]). If the patch
    fails, disconnect the session and propagate the error.
  - In copilot-cli mode, the same patch is sent only for fields the
    caller explicitly provided on the SessionConfig.
- Add `with_*` builders on SessionConfig and ResumeSessionConfig for
  the four opt-back-in fields.

Co-authored-by: Copilot <[email protected]>
Comment thread python/copilot/client.py
Comment on lines +37 to +48
from ._mode import (
BUILTIN_TOOLS_ISOLATED,
CopilotClientMode,
ToolSet,
_enable_session_telemetry_default,
_normalize_tool_filter,
_post_create_options_patch,
_require_available_tools_for_empty_mode,
_require_storage_for_empty_mode,
_system_message_for_mode,
_validate_tool_filter_list,
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant