Skip to content

Multitenancy hardening: Client Mode#1428

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

Multitenancy hardening: Client Mode#1428
SteveSandersonMS wants to merge 3 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).

Steve Sanderson and others added 2 commits May 26, 2026 13:36
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>
@SteveSandersonMS SteveSandersonMS changed the title Multitenancy hardening: Session profiles via Mode=empty (Node SDK) Multitenancy hardening: Client Mode May 26, 2026
…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>
@github-actions
Copy link
Copy Markdown
Contributor

Cross-SDK Consistency Review

The PR is clearly marked as a draft with explicit follow-up tasks for the other SDKs — this review just maps out the gaps to inform those follow-ups.

Changes in this PR (Node.js only)

Feature Node.js Python Go .NET Java Rust
mode: "empty" on client options
ToolSet builder (builtin:*, mcp:*, custom:*)
BuiltInTools enum / Isolated curated set
Bare "*" validation with actionable error
toolFilterMode defaulting to "denyPrecedence" in empty mode
availableTools / excludedTools accept ToolSet | string[] string[] only []string only IList<string> only

Notes for follow-up SDKs

Python (python/copilot/client.py, session.py)

  • Add mode: Literal["copilot-cli", "empty"] to client options
  • Add a ToolSet helper class (or equivalent builder) in a new tool_set.py
  • Add BuiltInTools constants
  • Validate bare "*" in available_tools / excluded_tools
  • Default tool_filter_mode to "denyPrecedence" when mode="empty"

Go (go/types.go, go/client.go)

  • Add Mode string (or typed const) to CopilotClientOptions
  • Add ToolSet type with fluent AddBuiltIn/AddMcp/AddCustom methods
  • Add BuiltInTools constants
  • Validate bare "*" in AvailableTools / ExcludedTools
  • Default ToolFilterMode to "denyPrecedence" when Mode == "empty"

.NET (dotnet/src/Types.cs, dotnet/src/Client.cs)

  • Add Mode property (string or enum) to CopilotClientOptions
  • Add ToolSet builder class
  • Add BuiltInTools constants
  • Validate bare "*" in AvailableTools / ExcludedTools
  • Default ToolFilterMode to "denyPrecedence" when Mode == "empty"

Java / Rust — same surface area as above, adjusted for language conventions.

What already exists

All existing SDKs already have availableTools/excludedTools as plain string lists, so the wire protocol plumbing is there — the follow-ups are primarily about adding the mode option, the ToolSet builder ergonomics, BuiltInTools constants, and the validation/defaulting logic.

The Node.js implementation in this PR is clean and the design is straightforward to port. Good foundation! 🎉

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

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