Skip to content

Multitenancy hardening: Client Mode#1428

Merged
SteveSandersonMS merged 17 commits into
mainfrom
stevesandersonms/session-profiles
May 27, 2026
Merged

Multitenancy hardening: Client Mode#1428
SteveSandersonMS merged 17 commits into
mainfrom
stevesandersonms/session-profiles

Conversation

@SteveSandersonMS

@SteveSandersonMS SteveSandersonMS commented May 26, 2026

Copy link
Copy Markdown
Contributor

Why

By default, CopilotClient gives every session the full Copilot CLI experience: every built-in tool, every host-side capability, user files like AGENTS.md / co-author trailers / plugins / custom agents, and a system prompt full of environment context.

That is great for an IDE plugin, but unsafe for a multi-tenant app where each session may belong to a different end user. There is no single switch to opt out of the host integration and start from a clean slate.

What

Add CopilotClientMode.Empty (TS mode: "empty"). When set, the SDK flips the host-integration defaults to safe values and requires every session to declare its tool surface up front via availableTools. The runtime stays mode-agnostic; this is a thin SDK-side translation.

Empty mode contract

  • Tool surface defaults to empty. availableTools is required on every session; pass a ToolSet (or string[]). excludedTools composes with it via deny-precedence so callers can write "everything matching X except Y".
  • Host integration disabled: per-user installed plugins, custom instructions, custom agents on disk, the co-author trailer, the manage-schedule tool, and session telemetry are all forced off (caller-supplied values still win).
  • System prompt sanitized: the environment_context section (working directory, OS, etc.) is stripped unless the caller explicitly opts in.
  • System keychain integration is disabled
  • Storage is required: baseDirectory (or sessionFs) must be set so session data doesn't leak into ~/.copilot by mistake

TypeScript

const client = new CopilotClient({
    mode: "empty",
    baseDirectory: "/tmp/per-tenant-state",
});

const session = await client.createSession({
    onPermissionRequest: approveAll,
    availableTools: new ToolSet()
        .addBuiltIn(BuiltInTools.Isolated)
        .addMcp("*"),
    excludedTools: new ToolSet()
        .addMcp("github-delete_repository"),
});

C#

using var client = new CopilotClient(new CopilotClientOptions
{
    Mode = CopilotClientMode.Empty,
    BaseDirectory = "/tmp/per-tenant-state",
});

await using var session = await client.CreateSessionAsync(new SessionConfig
{
    OnPermissionRequest = PermissionHandler.ApproveAll,
    AvailableTools = new ToolSet()
        .AddBuiltIn(BuiltInTools.Isolated)
        .AddMcp("*"),
    ExcludedTools = new ToolSet()
        .AddMcp("github-delete_repository"),
});

Coverage

TypeScript, Python, Go, C#, and Rust SDKs. Mirrored E2E tests across all five SDKs (shared cassettes under test/snapshots/mode_empty/) verify the SDK translation reaches the runtime correctly: the tool list the LLM sees, the stripped system message, and end-to-end behavior on representative prompts.

Remaining work

This PR establishes the pattern for setting empty mode, but doesn't implement all the runtime flags for disabling all the things we want to disable in that mode. The remaining parts are tracked in different issue that @MackinnonBuck is handling.

@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.

@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.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

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 thread dotnet/test/E2E/ModeEmptyE2ETests.cs Fixed
Comment thread dotnet/test/E2E/ModeEmptyE2ETests.cs Fixed
Comment thread dotnet/test/E2E/ModeEmptyE2ETests.cs Fixed
Comment thread dotnet/test/E2E/ModeEmptyE2ETests.cs Fixed
Comment thread dotnet/test/E2E/ModeEmptyE2ETests.cs Fixed
@github-actions

This comment has been minimized.

Comment thread python/copilot/client.py Fixed
Comment thread python/e2e/test_mode_empty_e2e.py Fixed
[Fact]
public void CopilotClient_Mode_Empty_Accepts_Base_Directory()
{
var dir = Path.Combine(Path.GetTempPath(), "copilot-empty-mode-test-" + Guid.NewGuid().ToString("N"));
@SteveSandersonMS SteveSandersonMS force-pushed the stevesandersonms/session-profiles branch from d11f417 to f61b858 Compare May 27, 2026 16:35
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@SteveSandersonMS SteveSandersonMS marked this pull request as ready for review May 27, 2026 17:01
@SteveSandersonMS SteveSandersonMS requested a review from a team as a code owner May 27, 2026 17:01
Copilot AI review requested due to automatic review settings May 27, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in “empty” client mode across SDKs to support multi-tenant / untrusted-prompt hosts by forcing explicit tool allowlisting, stripping environment context from system prompts by default, and applying additional safe session options after create/resume.

Changes:

  • Introduces mode="empty" / ClientMode::Empty / ModeEmpty / CopilotClientMode.Empty and enforces extra validation (explicit storage + explicit availableTools).
  • Adds ToolSet builders and “isolated” built-in tool allowlists, plus tool-filter validation (reject bare "*").
  • Adds E2E + unit coverage across Node/Python/Go/Rust/.NET and shared CAPI proxy snapshots; updates wire payloads to send toolFilterPrecedence="excluded".
Show a summary per file
File Description
test/snapshots/mode_empty/empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml New empty-mode E2E cassette for systemMessage: replace.
test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml New empty-mode E2E cassette verifying env-context stripping.
test/snapshots/mode_empty/empty_mode_isolated_set_shell_tool_is_not_exposed.yaml New empty-mode E2E cassette for isolated built-in tool set.
test/snapshots/mode_empty/empty_mode_excluded_tools_subtracts_from_available_tools.yaml New empty-mode E2E cassette for allowlist+denylist composition.
test/snapshots/mode_empty/empty_mode_builtin_star_exposes_all_built_in_tools.yaml New empty-mode E2E cassette for builtin:*.
test/snapshots/mode_empty/empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml New empty-mode E2E cassette for append→customize promotion + env stripping.
rust/tests/e2e/mode_empty.rs Rust E2E coverage mirroring Node cassettes.
rust/tests/e2e.rs Registers the new Rust E2E module.
rust/src/wire.rs Adds toolFilterPrecedence to create/resume wire payloads.
rust/src/types.rs Adds session option fields and ensures wire payload emits deny-wins precedence.
rust/src/session.rs Enforces empty-mode requirements, strips env context, defaults telemetry off, applies post-create option patch.
rust/src/mode.rs New Rust mode + ToolSet + isolated allowlist + validation helpers.
rust/src/lib.rs Exposes mode module + re-exports ClientMode, ToolSet, BUILTIN_TOOLS_ISOLATED; adds client option mode.
rust/src/generated/api_types.rs Schema/codegen updates (incl. options update enum + MCP auth config reshaping).
python/test_tool_set.py Python unit tests for ToolSet + empty-mode helpers.
python/e2e/test_mode_empty_e2e.py Python E2E tests sharing the same recorded snapshots.
python/copilot/client.py Adds mode, ToolSet support for tool filters, empty-mode defaults, and post-create patching.
python/copilot/_mode.py Implements Python ToolSet + isolated list + empty-mode validation/defaulting helpers.
python/copilot/init.py Re-exports ToolSet / mode types / isolated list.
nodejs/test/toolSet.test.ts Node unit tests for ToolSet, empty-mode validation/defaulting, and wire normalization.
nodejs/test/e2e/mode_empty.e2e.test.ts Node E2E tests for empty-mode behavior + tool exposure + env stripping.
nodejs/src/types.ts Adds CopilotClientMode, ToolSet-typed tool filters, and new session option flags.
nodejs/src/toolSet.ts Adds ToolSet builder and BuiltInTools.Isolated curated set.
nodejs/src/index.ts Exports ToolSet + BuiltInTools; exports CopilotClientMode type.
nodejs/src/generated/rpc.ts Schema/codegen updates (incl. toolFilterPrecedence type + MCP auth config reshaping).
nodejs/src/client.ts Implements empty-mode validation/defaulting, ToolSet normalization, toolFilterPrecedence, and post-create option patching.
nodejs/package.json Bumps @github/copilot dependency.
nodejs/package-lock.json Lockfile update for @github/copilot bump.
go/types.go Adds ClientOptions.Mode and new session option flags; wires toolFilterPrecedence + related fields.
go/toolset.go Adds Go ClientMode, ToolSet builder, and isolated built-in tool list.
go/toolset_test.go Go unit tests for ToolSet + empty-mode helpers.
go/rpc/zrpc.go Generated RPC updates for MCP auth config + toolFilterPrecedence enum.
go/rpc/zrpc_encoding.go Encoding updates for MCP auth config union decoding.
go/mode_empty.go Implements Go empty-mode validation/defaulting and post-create options patch.
go/internal/e2e/mode_empty_e2e_test.go Go E2E tests sharing recorded snapshots.
go/client.go Hooks empty-mode defaults/validation into create/resume + CLI env updates (disable keytar).
dotnet/test/Unit/ToolSetTests.cs .NET unit coverage for ToolSet and empty-mode construction constraints.
dotnet/test/E2E/ModeEmptyE2ETests.cs .NET E2E coverage mirroring other SDKs and recorded snapshots.
dotnet/src/Types.cs Adds CopilotClientMode + new session option flags and docs.
dotnet/src/ToolSet.cs Adds .NET ToolSet builder + BuiltInTools.Isolated.
dotnet/src/Generated/Rpc.cs Generated RPC updates for toolFilterPrecedence option.
dotnet/src/Client.cs Implements empty-mode validation/defaulting, tool filter validation, toolFilterPrecedence, and post-create option patching.

Copilot's findings

Files not reviewed (3)
  • go/rpc/zrpc.go: Language not supported
  • go/rpc/zrpc_encoding.go: Language not supported
  • nodejs/package-lock.json: Language not supported
Comments suppressed due to low confidence (2)

dotnet/src/Client.cs:855

  • If UpdateSessionOptionsForModeAsync throws after session.create succeeds, the catch block only calls RemoveFromClient() (unregisters locally) and does not send session.destroy to the runtime. In empty mode this can leave a running session with defaults the SDK failed to harden. Consider best-effort await session.DisposeAsync() / session.destroy in the failure path once the session has been created/resumed.
            session.WorkspacePath = response.WorkspacePath;
            session.SetCapabilities(response.Capabilities);
            session.SetOpenCanvases(response.OpenCanvases);

            await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            session.RemoveFromClient();
            if (ex is not OperationCanceledException)
            {
                LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex,
                    "CopilotClient.CreateSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}",
                    totalTimestamp,
                    sessionId);
            }
            throw;

dotnet/src/Client.cs:1030

  • ResumeSessionAsync has the same failure-mode as CreateSessionAsync: if UpdateSessionOptionsForModeAsync (or any later step) throws after the resume RPC succeeds, the catch path only unregisters locally and does not send session.destroy to the runtime. Consider best-effort disposing/destroying the runtime session in this failure path, especially for empty mode hardening.
            await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            session.RemoveFromClient();
            if (ex is not OperationCanceledException)
            {
                LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex,
                    "CopilotClient.ResumeSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}",
                    totalTimestamp,
                    sessionId);
            }
            throw;
  • Files reviewed: 36/43 changed files
  • Comments generated: 6

Comment thread nodejs/src/client.ts Outdated
Comment thread nodejs/src/client.ts
Comment thread nodejs/src/client.ts
Comment thread nodejs/src/toolSet.ts
Comment thread python/copilot/_mode.py Outdated
Comment thread go/toolset.go Outdated
@github-actions

This comment has been minimized.

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…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 <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
krukow added a commit to copilot-community-sdk/copilot-sdk-clojure that referenced this pull request Jun 2, 2026
…114)

* feat(tool-set): add github.copilot-sdk.tool-set namespace + isolated preset

Source-qualified tool filter constructors (builtin/mcp/custom + builtins
vector form) plus isolated-builtins / isolated for parity with upstream
BuiltInTools.Isolated. Bare "*" is rejected at construction time.

Adds fdef specs in instrument.clj for every public fn so integration
tests with instrumentation enabled catch contract violations.

Mirrors upstream nodejs/src/toolSet.ts from PR github/copilot-sdk#1428
github/copilot-sdk#1428

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(client): implement client mode :empty (multitenancy hardening)

Add :mode #{:copilot-cli :empty} client option (default :copilot-cli)
mirroring upstream PR github/copilot-sdk#1428.

In :empty mode the SDK:

- Requires at least one tenant-scoped storage root at construction time
  (:copilot-home, :session-fs, :cli-url, or :is-child-process?) so the
  spawned CLI never falls back to the user's home directory.
- Forces COPILOT_DISABLE_KEYTAR=1 on the spawned CLI via the
  cli-env-overrides :overrides slot so the caller cannot accidentally
  re-enable the host keychain.
- Requires every create-session / resume-session call (sync + async) to
  supply :available-tools; an empty vector is legitimate, the key just
  has to be present so silently-empty filters cannot happen.
- Spreads 9 safe defaults UNDER caller session config (caller always
  wins): :enable-session-telemetry? false,
  :mcp-oauth-token-storage :in-memory, :skip-embedding-retrieval true,
  :embedding-cache-storage :in-memory,
  :enable-on-demand-instruction-discovery false,
  :enable-file-hooks false, :enable-host-git-operations false,
  :enable-session-store false, :enable-skills false.
- Normalizes :system-message so environment_context is stripped unless
  the app has taken control of it (mirrors upstream
  getSystemMessageConfigForMode): no system-message emits
  {:mode customize :sections {:environment_context {:action remove}}};
  :append is promoted to :customize preserving content; :customize
  without an env-context override gets one added; :replace passes
  through unchanged. :copilot-cli mode keeps legacy behavior.
- After session.create / session.resume succeeds, issues a follow-up
  session.options.update RPC carrying four overridable feature flags
  (:skip-custom-instructions true, :custom-agents-local-only true,
  :coauthor-enabled false, :manage-schedule-enabled false) plus
  :installed-plugins []. In :copilot-cli mode only flags the caller
  explicitly set are forwarded; an empty patch skips the RPC entirely.
  On failure the SDK disconnects and removes the half-configured
  session before rethrowing. Wired into create-session,
  resume-session, <create-session, <resume-session.

Both modes always emit :tool-filter-precedence "excluded" on
session.create / session.resume so the ordering between
:available-tools and :excluded-tools is deterministic regardless of
CLI version, and reject bare "*" in :available-tools / :excluded-tools
at the SDK boundary (matches upstream resolveToolFilterOptions).

Adds 23 new integration tests covering validation, env-var overrides,
wire payload mode-defaults, system-message normalization, and the
session.options.update RPC (including async path + cleanup-on-failure).
339 tests / 1578 assertions / 0 failures.

Upstream: github/copilot-sdk#1428

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: document client mode :empty + empty_mode example

- doc/reference/API.md: add :mode client option to constructor table,
  add 4 new session-config flags (skip-custom-instructions,
  custom-agents-local-only, coauthor-enabled, manage-schedule-enabled)
  to the session-config table, and add two new sections under Advanced
  Usage:
  - 'Client Mode (Empty)' — runnable example, mode semantics, the 9
    config defaults, system-message normalization, options.update
    flags, and the always-emit tool-filter-precedence guarantee.
  - 'Tool Sets' — github.copilot-sdk.tool-set API surface (builtin /
    mcp / custom / builtins / isolated) with the bare-* rejection
    rationale.
- CHANGELOG.md: new [Unreleased] section for upstream PR #1428,
  remove the matching 'deferred from round 6' bullet (no longer
  deferred — ported here).
- examples/empty_mode.clj: BYOK-based runnable example showing temp
  copilot-home + in-memory session-fs + tool-set/isolated. Excluded
  from run-all-examples.sh because empty mode disables the local
  keychain and the example requires OPENAI_API_KEY or
  ANTHROPIC_API_KEY.
- examples/README.md: example 20 entry, prerequisites note.
- doc/index.md: bump example count to 20.

Upstream: github/copilot-sdk#1428

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(client): offload blocking options.update cleanup to async/thread

The async <apply-session-options-update! ran cleanup-failed-options-update!
directly inside its go block on RPC failure. That cleanup calls session/disconnect!,
which uses blocking proto/send-request! (5s timeout) and other side effects.
Blocking work inside a go block can starve the core.async dispatch threadpool
and stall unrelated async flows.

Offload the cleanup to async/thread and <! it from the go block before
delivering the Throwable, matching the existing convention used in
session.clj for blocking user-handler work.

Also adds:
- A regression test (test-empty-mode-options-update-async-failure-cleans-up-session)
  asserting the async path yields a Throwable and removes the half-configured
  session from the registry after an options.update failure.
- A regression test (test-empty-mode-system-message-customize-no-sections-key)
  covering :customize without a :sections key — locks in the existing
  (contains? nil ...) → false → add :sections semantics so the behavior
  cannot regress.

Addresses Copilot Code Review feedback on PR #114.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

2 participants